From fe9ba94bacfd09101af64a335da6ddca7dcb7a60 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Mon, 3 Aug 2026 21:34:30 +0800 Subject: [PATCH 1/6] feat(i18n): add model routes translations and enhance graph settings fallback - Updated English and Chinese locale files to include new translations for model routes and design prototype features. - Enhanced the fallback mechanism in `i18n.ts` to include model routes in the English Graph/Storage bundle. - Improved the `withGraphSettingsFallback` function to merge settings more effectively. test(chat): add tests for guiding queued design messages - Introduced tests to validate the guidance of queued design canvas messages using their visible user text. - Updated the `queued-message-guidance` module to handle design canvas messages appropriately. refactor(store): streamline queued message guidance logic - Refactored the `queued-message-guidance` module to improve clarity and maintainability. - Updated the logic to determine if a queued message can be guided based on its properties. --- build/installer.nsh | 344 ++++++--- build/windows-installer-migration.ps1 | 663 ++++++++++++++++-- ...ompat-model-client.endpoint-format.test.ts | 57 +- .../adapters/model/compat-request-codecs.ts | 9 +- kun/src/cli/serve-entry.ts | 62 +- kun/src/cli/shared-runtime.test.ts | 132 +++- kun/src/cli/shared-runtime.ts | 159 ++++- kun/src/manager/manager-client.ts | 25 +- kun/src/manager/service-manager.test.ts | 93 +++ kun/src/manager/service-manager.ts | 20 +- kun/src/server/node-http-server.test.ts | 47 +- kun/src/server/node-http-server.ts | 27 +- kun/src/server/runtime-factory.ts | 47 +- .../services/provider-quota-service.test.ts | 185 +++++ kun/src/services/provider-quota-service.ts | 4 +- .../services/provider-subscription-quota.ts | 295 ++++++-- scripts/smoke-windows-installer-migration.ps1 | 148 +++- src/main/gui-updater.test.ts | 57 ++ src/main/gui-updater.ts | 24 +- src/main/index.ts | 9 +- src/main/ipc/register-app-ipc-handlers.ts | 4 +- .../register-extension-ipc-handlers.test.ts | 67 ++ .../ipc/register-extension-ipc-handlers.ts | 72 +- src/main/packaging-config.test.ts | 68 +- src/main/provider-quota.test.ts | 154 ++++ src/main/provider-quota.ts | 4 +- src/main/provider-subscription-quota.ts | 286 ++++++-- src/main/runtime-provider-quota.test.ts | 37 + src/main/runtime-provider-quota.ts | 39 ++ src/main/windows-installer-migration.test.ts | 383 +++++++++- .../chat/FloatingComposerQueuedMessages.tsx | 14 +- .../components/design/DesignAIRail.test.ts | 11 +- .../components/design/DesignSidebar.test.ts | 23 + .../src/components/design/DesignSidebar.tsx | 27 +- .../canvas/PrototypePlayerOverlay.test.ts | 34 +- .../design/canvas/PrototypePlayerOverlay.tsx | 170 ++++- .../settings-section-model-routes.test.ts | 118 ++-- .../settings-section-model-routes.tsx | 337 +++++---- src/renderer/src/i18n.ts | 22 +- src/renderer/src/locales/en/common.json | 2 + src/renderer/src/locales/en/settings.json | 164 +++++ src/renderer/src/locales/zh/common.json | 2 + src/renderer/src/locales/zh/settings.json | 164 +++++ .../store/chat-store-thread-actions.test.ts | 32 + .../src/store/chat-store-thread-actions.ts | 19 +- .../src/store/queued-message-guidance.test.ts | 42 +- .../src/store/queued-message-guidance.ts | 77 +- 47 files changed, 4159 insertions(+), 620 deletions(-) create mode 100644 src/main/runtime-provider-quota.test.ts create mode 100644 src/main/runtime-provider-quota.ts diff --git a/build/installer.nsh b/build/installer.nsh index 4b4e66126..395676fbe 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -8,6 +8,14 @@ Var /GLOBAL KunInstallerResultHandle Var /GLOBAL KunInstallerJournalPath Var /GLOBAL KunInstallerMigrationPrepared Var /GLOBAL KunInstallerSnapshotMode +Var /GLOBAL KunInstallerPrimarySourceStale +Var /GLOBAL KunInstallerSecondarySourceStale +Var /GLOBAL KunInstallerCandidateExplicit +Var /GLOBAL KunInstallerPresentedTargetDir +Var /GLOBAL KunInstallerUpdateSourceDir +Var /GLOBAL KunInstallerPreserveOtherScope +Var /GLOBAL KunInstallerOtherUninstallString +Var /GLOBAL KunInstallerOtherQuietUninstallString Var /GLOBAL KunInstallerRestoreInteractive Var /GLOBAL KunInstallerCurrentUserShortcutName Var /GLOBAL KunInstallerCurrentUserMenuDirectory @@ -45,6 +53,14 @@ Var /GLOBAL KunInstallerStopResult StrCpy $KunInstallerCurrentPid $0 StrCpy $KunInstallerMigrationPrepared 0 StrCpy $KunInstallerSnapshotMode "" + StrCpy $KunInstallerPrimarySourceStale 0 + StrCpy $KunInstallerSecondarySourceStale 0 + StrCpy $KunInstallerCandidateExplicit 0 + StrCpy $KunInstallerPresentedTargetDir "" + StrCpy $KunInstallerUpdateSourceDir "" + StrCpy $KunInstallerPreserveOtherScope 0 + StrCpy $KunInstallerOtherUninstallString "" + StrCpy $KunInstallerOtherQuietUninstallString "" !ifndef BUILD_UNINSTALLER StrCpy $KunInstallerRestoreInteractive 0 !endif @@ -57,6 +73,13 @@ Var /GLOBAL KunInstallerStopResult SetSilent silent ${endif} + !insertmacro GetDParameter $R0 + ${if} $R0 != "" + StrCpy $KunInstallerCandidateExplicit 1 + ${endif} + + Call KunSetProductEnvironment + Call KunSelectAutomaticUpdateMode Call KunRefreshInstallPaths ${if} ${UAC_IsInnerInstance} @@ -79,6 +102,9 @@ Var /GLOBAL KunInstallerStopResult StrCpy $KunInstallerCurrentPid $0 System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_APP_ROOT", "$INSTDIR").r0' System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_SELF_PID", "$KunInstallerCurrentPid").r0' + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_CANONICAL_LEAF", "${APP_FILENAME}").r0' + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_APP_EXECUTABLE", "${APP_EXECUTABLE_FILENAME}").r0' + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_PRODUCT_NAME", "${PRODUCT_NAME}").r0' StrCpy $KunInstallerStopAttempt 0 KunStopProcessesFromInstallDir: @@ -117,16 +143,36 @@ Var /GLOBAL KunInstallerStopResult !macroend !macro customUnInstallCheck - StrCpy $KunInstallerSourceDir $KunInstallerPrimarySourceDir - Call KunHandleOldUninstallerResult + ${if} $KunInstallerPrimarySourceStale != 1 + StrCpy $KunInstallerSourceDir $KunInstallerPrimarySourceDir + Call KunHandleOldUninstallerResult + ${else} + ClearErrors + StrCpy $R0 0 + ${endif} ${if} $installMode != "all" Call KunRestoreInteractiveInstaller + ${elseIf} $KunInstallerPreserveOtherScope == 1 + Call KunSuspendCurrentUserUninstallRegistration ${endif} !macroend !macro customUnInstallCheckCurrentUser - StrCpy $KunInstallerSourceDir $KunInstallerSecondarySourceDir - Call KunHandleOldUninstallerResult + ${if} $KunInstallerPreserveOtherScope == 1 + Call KunRestoreCurrentUserUninstallRegistration + ClearErrors + StrCpy $R0 0 + StrCpy $KunInstallerSourceDir $KunInstallerPrimarySourceDir + Call KunRestoreInteractiveInstaller + Return + ${endif} + ${if} $KunInstallerSecondarySourceStale != 1 + StrCpy $KunInstallerSourceDir $KunInstallerSecondarySourceDir + Call KunHandleOldUninstallerResult + ${else} + ClearErrors + StrCpy $R0 0 + ${endif} # installSection invokes this callback only while an all-users install is # retiring an existing current-user registration. The old uninstaller usually # removes this shell state itself; fallback cleanup must finish the same scoped @@ -169,6 +215,13 @@ Var /GLOBAL KunInstallerStopResult # template's installMode/appExe variables without forking the upstream script. !macro customHeader !ifndef BUILD_UNINSTALLER + Function KunSetProductEnvironment + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_CANONICAL_LEAF", "${APP_FILENAME}").r0' + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_APP_EXECUTABLE", "${APP_EXECUTABLE_FILENAME}").r0' + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_PRODUCT_NAME", "${PRODUCT_NAME}").r0' + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_APP_GUID", "${APP_GUID}").r0' + FunctionEnd + Function KunSetMigrationEnvironment # $APPDATA follows SetShellVarContext, so per-machine recovery is shared # while current-user recovery stays in the selected user's profile. @@ -179,6 +232,10 @@ Var /GLOBAL KunInstallerStopResult System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_TARGET", "$KunInstallerTargetDir").r0' System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_JOURNAL", "$KunInstallerJournalPath").r0' System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_SELF_PID", "$KunInstallerCurrentPid").r0' + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_PRIMARY_SOURCE_STALE", "$KunInstallerPrimarySourceStale").r0' + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_SECONDARY_SOURCE_STALE", "$KunInstallerSecondarySourceStale").r0' + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_CANDIDATE_EXPLICIT", "$KunInstallerCandidateExplicit").r0' + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_INSTALL_MODE", "$installMode").r0' FunctionEnd Function KunReadMigrationResult @@ -193,84 +250,102 @@ Var /GLOBAL KunInstallerStopResult KunMigrationResultMissing: StrCpy $KunInstallerHelperExitCode 1 - StrCpy $KunInstallerHelperOutput "The path resolver did not produce a result file." + StrCpy $KunInstallerHelperOutput "The installer helper did not produce a result file." Delete "$KunInstallerResultPath" FunctionEnd - Function KunGetInQuotes - Exch $R0 - Push $R1 - Push $R2 - - StrCpy $R1 -1 - KunGetInQuotesFindStart: - IntOp $R1 $R1 + 1 - StrCpy $R2 $R0 1 $R1 - StrCmp $R2 "" KunGetInQuotesInvalid - StrCmp $R2 '"' KunGetInQuotesStart KunGetInQuotesFindStart - - KunGetInQuotesStart: - IntOp $R1 $R1 + 1 - StrCpy $R0 $R0 "" $R1 - StrCpy $R1 0 - - KunGetInQuotesFindEnd: - IntOp $R1 $R1 + 1 - StrCpy $R2 $R0 1 $R1 - StrCmp $R2 "" KunGetInQuotesInvalid - StrCmp $R2 '"' KunGetInQuotesDone KunGetInQuotesFindEnd - - KunGetInQuotesInvalid: - StrCpy $R0 "" - Goto KunGetInQuotesReturn - - KunGetInQuotesDone: - StrCpy $R0 $R0 $R1 - - KunGetInQuotesReturn: - Pop $R2 - Pop $R1 - Exch $R0 + Function KunResolveRegisteredSource + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_SOURCE", "$KunInstallerSourceDir").r0' + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_UNINSTALL_STRING", "$R9").r0' + Delete "$KunInstallerResultPath" + !insertmacro kunRunMigrationHelper ResolveSource + ${if} $KunInstallerHelperExitCode == 0 + Call KunReadMigrationResult + ${endif} + ${if} $KunInstallerHelperExitCode != 0 + ${orIf} $KunInstallerHelperOutput == "" + MessageBox MB_OK|MB_ICONSTOP "Kun found an existing installation registration but could not recover its program directory.$\r$\n$KunInstallerHelperOutput" /SD IDOK + SetErrorLevel 2 + Quit + ${endif} + StrCpy $KunInstallerSourceDir $KunInstallerHelperOutput FunctionEnd - Function KunGetFileParent - Exch $R0 - Push $R1 - Push $R2 - Push $R3 - - StrCpy $R1 0 - StrLen $R2 $R0 - - KunGetFileParentLoop: - IntOp $R1 $R1 + 1 - IntCmp $R1 $R2 KunGetFileParentDone 0 KunGetFileParentDone - StrCpy $R3 $R0 1 -$R1 - StrCmp $R3 "\" KunGetFileParentDone KunGetFileParentLoop - - KunGetFileParentDone: - StrCpy $R0 $R0 -$R1 - Pop $R3 - Pop $R2 - Pop $R1 - Exch $R0 - FunctionEnd + Function KunSelectAutomaticUpdateMode + ${ifNot} ${isUpdated} + Return + ${endif} + ReadEnvStr $KunInstallerUpdateSourceDir "KUN_INSTALLER_UPDATE_SOURCE" + ${if} $KunInstallerUpdateSourceDir == "" + DetailPrint "Automatic update source marker is unavailable; using compatible single-registration selection." + Return + ${endif} - Function KunRecoverSourceFromUninstallString - StrCpy $KunInstallerSourceDir "" - Push "$R9" - Call KunGetInQuotes - Pop $KunInstallerSourceDir - ${if} $KunInstallerSourceDir != "" - Push $KunInstallerSourceDir - Call KunGetFileParent - Pop $KunInstallerSourceDir + ReadRegStr $R0 HKEY_CURRENT_USER "${INSTALL_REGISTRY_KEY}" InstallLocation + ReadRegStr $R1 HKEY_CURRENT_USER "${UNINSTALL_REGISTRY_KEY}" UninstallString + ReadRegStr $R2 HKEY_LOCAL_MACHINE "${INSTALL_REGISTRY_KEY}" InstallLocation + ReadRegStr $R3 HKEY_LOCAL_MACHINE "${UNINSTALL_REGISTRY_KEY}" UninstallString + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_CURRENT_USER_SOURCE", "$R0").r0' + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_CURRENT_USER_UNINSTALL_STRING", "$R1").r0' + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_ALL_USERS_SOURCE", "$R2").r0' + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_ALL_USERS_UNINSTALL_STRING", "$R3").r0' + Delete "$KunInstallerResultPath" + !insertmacro kunRunMigrationHelper ResolveUpdateScope + ${if} $KunInstallerHelperExitCode == 0 + Call KunReadMigrationResult ${endif} - ${if} $KunInstallerSourceDir == "" - MessageBox MB_OK|MB_ICONSTOP "Kun found an existing uninstall registration but could not recover its installation directory." /SD IDOK + ${if} $KunInstallerHelperExitCode != 0 + ${orIf} $KunInstallerHelperOutput == "" + MessageBox MB_OK|MB_ICONSTOP "${PRODUCT_NAME} could not match this automatic update to one installed application.$\r$\n$KunInstallerHelperOutput" /SD IDOK SetErrorLevel 2 Quit ${endif} + + ${if} $KunInstallerHelperOutput == "current" + StrCpy $hasPerMachineInstallation 0 + StrCpy $hasPerUserInstallation 1 + !insertmacro setInstallModePerUser + DetailPrint "Automatic update selected the current-user ${PRODUCT_NAME} registration." + Return + ${endif} + ${if} $KunInstallerHelperOutput == "all" + StrCpy $KunInstallerPreserveOtherScope 1 + StrCpy $hasPerMachineInstallation 1 + StrCpy $hasPerUserInstallation 0 + !insertmacro setInstallModePerAllUsers + DetailPrint "Automatic update selected the all-users ${PRODUCT_NAME} registration." + Return + ${endif} + + MessageBox MB_OK|MB_ICONSTOP "${PRODUCT_NAME} received an invalid automatic update scope: $KunInstallerHelperOutput" /SD IDOK + SetErrorLevel 2 + Quit + FunctionEnd + + Function KunRetireSelectedShellState + ReadRegStr $KunInstallerCurrentUserShortcutName SHELL_CONTEXT "${INSTALL_REGISTRY_KEY}" ShortcutName + ReadRegStr $KunInstallerCurrentUserMenuDirectory SHELL_CONTEXT "${INSTALL_REGISTRY_KEY}" MenuDirectory + + ${if} $KunInstallerCurrentUserShortcutName != "" + Delete "$DESKTOP\$KunInstallerCurrentUserShortcutName.lnk" + Delete "$SMPROGRAMS\$KunInstallerCurrentUserShortcutName.lnk" + ${if} $KunInstallerCurrentUserMenuDirectory != "" + Delete "$SMPROGRAMS\$KunInstallerCurrentUserMenuDirectory\$KunInstallerCurrentUserShortcutName.lnk" + ${endif} + ${endif} + + Delete "$DESKTOP\${SHORTCUT_NAME}.lnk" + Delete "$SMPROGRAMS\${SHORTCUT_NAME}.lnk" + Delete "$DESKTOP\DeepSeek GUI.lnk" + Delete "$SMPROGRAMS\DeepSeek GUI.lnk" + ${if} $KunInstallerCurrentUserMenuDirectory != "" + Delete "$SMPROGRAMS\$KunInstallerCurrentUserMenuDirectory\${SHORTCUT_NAME}.lnk" + Delete "$SMPROGRAMS\$KunInstallerCurrentUserMenuDirectory\DeepSeek GUI.lnk" + RMDir "$SMPROGRAMS\$KunInstallerCurrentUserMenuDirectory" + ${endif} + + DeleteRegKey SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" + DeleteRegKey SHELL_CONTEXT "${INSTALL_REGISTRY_KEY}" FunctionEnd Function KunRetireCurrentUserShellState @@ -303,33 +378,35 @@ Var /GLOBAL KunInstallerStopResult Function KunReadRegisteredSource ReadRegStr $KunInstallerSourceDir SHELL_CONTEXT "${INSTALL_REGISTRY_KEY}" InstallLocation - ${if} $KunInstallerSourceDir == "" - ReadRegStr $R9 SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" UninstallString - ${if} $R9 == "" - ${andIf} $installMode != "all" - ReadRegStr $R9 HKEY_CURRENT_USER "${UNINSTALL_REGISTRY_KEY}" UninstallString - ${endif} - ${if} $R9 != "" - Call KunRecoverSourceFromUninstallString - ${endif} + ReadRegStr $R9 SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" UninstallString + ${if} $R9 == "" + ${andIf} $installMode != "all" + ReadRegStr $R9 HKEY_CURRENT_USER "${UNINSTALL_REGISTRY_KEY}" UninstallString + ${endif} + ${if} $KunInstallerSourceDir != "" + ${orIf} $R9 != "" + Call KunResolveRegisteredSource ${endif} StrCpy $KunInstallerPrimarySourceDir $KunInstallerSourceDir StrCpy $KunInstallerSecondarySourceDir "" ${if} $installMode == "all" - ReadRegStr $KunInstallerSecondarySourceDir HKEY_CURRENT_USER "${INSTALL_REGISTRY_KEY}" InstallLocation - ${if} $KunInstallerSecondarySourceDir == "" - ReadRegStr $R9 HKEY_CURRENT_USER "${UNINSTALL_REGISTRY_KEY}" UninstallString - ${if} $R9 != "" - Call KunRecoverSourceFromUninstallString - StrCpy $KunInstallerSecondarySourceDir $KunInstallerSourceDir - ${endif} + ${andIf} $KunInstallerPreserveOtherScope != 1 + StrCpy $KunInstallerSourceDir "" + ReadRegStr $KunInstallerSourceDir HKEY_CURRENT_USER "${INSTALL_REGISTRY_KEY}" InstallLocation + ReadRegStr $R9 HKEY_CURRENT_USER "${UNINSTALL_REGISTRY_KEY}" UninstallString + ${if} $KunInstallerSourceDir != "" + ${orIf} $R9 != "" + Call KunResolveRegisteredSource ${endif} + StrCpy $KunInstallerSecondarySourceDir $KunInstallerSourceDir ${endif} + StrCpy $KunInstallerSourceDir $KunInstallerPrimarySourceDir FunctionEnd Function KunResolveInstallTarget System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_SOURCE", "$KunInstallerSourceDir").r0' System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_CANDIDATE", "$INSTDIR").r0' + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_CANDIDATE_EXPLICIT", "$KunInstallerCandidateExplicit").r0' Delete "$KunInstallerResultPath" !insertmacro kunRunMigrationHelper ResolvePath ${if} $KunInstallerHelperExitCode == 0 @@ -362,9 +439,13 @@ Var /GLOBAL KunInstallerStopResult Abort ${endif} Call KunRefreshInstallPaths + StrCpy $KunInstallerPresentedTargetDir $INSTDIR FunctionEnd Function KunInstallDirectoryPageLeave + ${if} $INSTDIR != $KunInstallerPresentedTargetDir + StrCpy $KunInstallerCandidateExplicit 1 + ${endif} Call KunRefreshInstallPaths FunctionEnd @@ -377,15 +458,102 @@ Var /GLOBAL KunInstallerStopResult Return ${endif} Call KunRefreshInstallPaths + Delete "$KunInstallerResultPath" !insertmacro kunRunMigrationHelper Prepare ${if} $KunInstallerHelperExitCode != 0 MessageBox MB_OK|MB_ICONSTOP "Kun kept the existing installation unchanged because it could not migrate the program directory safely.$\r$\n$KunInstallerHelperOutput" /SD IDOK SetErrorLevel 2 Quit ${endif} + Call KunReadMigrationResult + ${if} $KunInstallerHelperExitCode != 0 + MessageBox MB_OK|MB_ICONSTOP "Kun kept the existing installation unchanged because it could not classify the registered program directory safely.$\r$\n$KunInstallerHelperOutput" /SD IDOK + SetErrorLevel 2 + Quit + ${endif} + + ${if} $KunInstallerHelperOutput == "1" + ${orIf} $KunInstallerHelperOutput == "3" + StrCpy $KunInstallerPrimarySourceStale 1 + DetailPrint "Retiring stale selected-scope Kun registration without modifying $KunInstallerPrimarySourceDir." + Call KunRetireSelectedShellState + ${else} + StrCpy $KunInstallerSourceDir $KunInstallerPrimarySourceDir + Call KunSecureSelectedUninstallRegistration + ${endif} + ${if} $KunInstallerHelperOutput == "2" + ${orIf} $KunInstallerHelperOutput == "3" + StrCpy $KunInstallerSecondarySourceStale 1 + DetailPrint "Retiring stale current-user Kun registration without modifying $KunInstallerSecondarySourceDir." + Call KunRetireCurrentUserShellState + ${elseIf} $KunInstallerSecondarySourceDir != "" + StrCpy $KunInstallerSourceDir $KunInstallerSecondarySourceDir + Call KunSecureCurrentUserUninstallRegistration + ${endif} + StrCpy $KunInstallerSourceDir $KunInstallerPrimarySourceDir StrCpy $KunInstallerMigrationPrepared 1 FunctionEnd + Function KunSuspendCurrentUserUninstallRegistration + ReadRegStr $KunInstallerOtherUninstallString HKEY_CURRENT_USER "${UNINSTALL_REGISTRY_KEY}" UninstallString + ReadRegStr $KunInstallerOtherQuietUninstallString HKEY_CURRENT_USER "${UNINSTALL_REGISTRY_KEY}" QuietUninstallString + DeleteRegValue HKEY_CURRENT_USER "${UNINSTALL_REGISTRY_KEY}" UninstallString + DeleteRegValue HKEY_CURRENT_USER "${UNINSTALL_REGISTRY_KEY}" QuietUninstallString + DetailPrint "Preserving the unrelated current-user ${PRODUCT_NAME} registration during an all-users automatic update." + FunctionEnd + + Function KunRestoreCurrentUserUninstallRegistration + ${if} $KunInstallerOtherUninstallString != "" + WriteRegStr HKEY_CURRENT_USER "${UNINSTALL_REGISTRY_KEY}" UninstallString "$KunInstallerOtherUninstallString" + ${endif} + ${if} $KunInstallerOtherQuietUninstallString != "" + WriteRegStr HKEY_CURRENT_USER "${UNINSTALL_REGISTRY_KEY}" QuietUninstallString "$KunInstallerOtherQuietUninstallString" + ${endif} + FunctionEnd + + Function KunResolveTrustedUninstaller + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_SOURCE", "$KunInstallerSourceDir").r0' + Delete "$KunInstallerResultPath" + !insertmacro kunRunMigrationHelper ResolveUninstaller + ${if} $KunInstallerHelperExitCode == 0 + Call KunReadMigrationResult + ${endif} + ${if} $KunInstallerHelperExitCode != 0 + MessageBox MB_OK|MB_ICONSTOP "${PRODUCT_NAME} could not validate the old application uninstaller.$\r$\n$KunInstallerHelperOutput" /SD IDOK + SetErrorLevel 2 + Quit + ${endif} + FunctionEnd + + Function KunSecureSelectedUninstallRegistration + Call KunResolveTrustedUninstaller + ${if} $KunInstallerHelperOutput == "" + DeleteRegValue SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" UninstallString + DeleteRegValue SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" QuietUninstallString + DetailPrint "The old ${PRODUCT_NAME} uninstaller is unavailable; conservative cleanup will be used." + Return + ${endif} + ${if} $installMode == "all" + StrCpy $R8 "/allusers" + ${else} + StrCpy $R8 "/currentuser" + ${endif} + WriteRegStr SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" UninstallString '"$KunInstallerHelperOutput" $R8' + WriteRegStr SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" QuietUninstallString '"$KunInstallerHelperOutput" $R8 /S' + FunctionEnd + + Function KunSecureCurrentUserUninstallRegistration + Call KunResolveTrustedUninstaller + ${if} $KunInstallerHelperOutput == "" + DeleteRegValue HKEY_CURRENT_USER "${UNINSTALL_REGISTRY_KEY}" UninstallString + DeleteRegValue HKEY_CURRENT_USER "${UNINSTALL_REGISTRY_KEY}" QuietUninstallString + DetailPrint "The old current-user ${PRODUCT_NAME} uninstaller is unavailable; conservative cleanup will be used." + Return + ${endif} + WriteRegStr HKEY_CURRENT_USER "${UNINSTALL_REGISTRY_KEY}" UninstallString '"$KunInstallerHelperOutput" /currentuser' + WriteRegStr HKEY_CURRENT_USER "${UNINSTALL_REGISTRY_KEY}" QuietUninstallString '"$KunInstallerHelperOutput" /currentuser /S' + FunctionEnd + Function KunHandleOldUninstallerResult IfErrors KunOldUninstallerFailed KunOldUninstallerFinished diff --git a/build/windows-installer-migration.ps1 b/build/windows-installer-migration.ps1 index 1b9203b35..69bdcc1d4 100644 --- a/build/windows-installer-migration.ps1 +++ b/build/windows-installer-migration.ps1 @@ -1,6 +1,6 @@ param( [Parameter(Mandatory = $true)] - [ValidateSet('ResolvePath', 'ResolveSource', 'StopProcesses', 'Recover', 'Prepare', 'FallbackCleanup', 'Restore', 'UpdatePath')] + [ValidateSet('ResolvePath', 'ResolveSource', 'ResolveUpdateScope', 'ResolveUninstaller', 'StopProcesses', 'Recover', 'Prepare', 'FallbackCleanup', 'Restore', 'UpdatePath')] [string]$Action, [string]$ResultPath = '' ) @@ -12,6 +12,50 @@ function Get-EnvironmentValue([string]$Name) { return [Environment]::GetEnvironmentVariable($Name, 'Process') } +function Get-CanonicalLeaf { + $value = (Get-EnvironmentValue 'KUN_INSTALLER_CANONICAL_LEAF').Trim() + if ([string]::IsNullOrWhiteSpace($value)) { + return 'Kun' + } + if ($value.IndexOfAny([IO.Path]::GetInvalidFileNameChars()) -ge 0 -or + $value.Contains('\') -or $value.Contains('/')) { + throw "The canonical application directory leaf is invalid: $value" + } + return $value +} + +function Test-ProductionInstallerIdentity { + return [string]::Equals((Get-CanonicalLeaf), 'Kun', [StringComparison]::OrdinalIgnoreCase) +} + +function Get-ApplicationIdentityFiles { + $configured = (Get-EnvironmentValue 'KUN_INSTALLER_APP_EXECUTABLE').Trim() + $values = @() + if (-not [string]::IsNullOrWhiteSpace($configured)) { + $values += $configured + } else { + $values += ((Get-CanonicalLeaf) + '.exe') + } + if (Test-ProductionInstallerIdentity) { + $values += @('Kun.exe', 'DeepSeek GUI.exe') + } + return @($values | Select-Object -Unique) +} + +function Get-AppSpecificUninstallerFiles { + $configured = (Get-EnvironmentValue 'KUN_INSTALLER_PRODUCT_NAME').Trim() + $values = @() + if (-not [string]::IsNullOrWhiteSpace($configured)) { + $values += ('Uninstall ' + $configured + '.exe') + } else { + $values += ('Uninstall ' + (Get-CanonicalLeaf) + '.exe') + } + if (Test-ProductionInstallerIdentity) { + $values += @('Uninstall Kun.exe', 'Uninstall DeepSeek GUI.exe') + } + return @($values | Select-Object -Unique) +} + function Write-InstallerDiagnostic([string]$Message) { $diagnosticPath = Get-EnvironmentValue 'KUN_INSTALLER_DIAGNOSTIC_PATH' if ([string]::IsNullOrWhiteSpace($diagnosticPath)) { @@ -70,6 +114,9 @@ function Test-PathWithin([string]$PathValue, [string]$RootValue) { } function Test-LegacyLeaf([string]$Leaf) { + if (-not (Test-ProductionInstallerIdentity)) { + return $false + } return [string]::Equals($Leaf, 'DeepSeek GUI', [StringComparison]::OrdinalIgnoreCase) -or [string]::Equals($Leaf, 'deepseek-gui', [StringComparison]::OrdinalIgnoreCase) } @@ -80,12 +127,13 @@ function Resolve-LegacySourceTarget([string]$Source) { } $sourceLeaf = Split-Path -Leaf $Source $sourceParent = Split-Path -Parent $Source + $canonicalLeaf = Get-CanonicalLeaf if (Test-LegacyLeaf $sourceLeaf) { - return Join-Path $sourceParent 'Kun' + return Join-Path $sourceParent $canonicalLeaf } - if ([string]::Equals($sourceLeaf, 'Kun', [StringComparison]::OrdinalIgnoreCase) -and + if ([string]::Equals($sourceLeaf, $canonicalLeaf, [StringComparison]::OrdinalIgnoreCase) -and (Test-LegacyLeaf (Split-Path -Leaf $sourceParent))) { - return Join-Path (Split-Path -Parent $sourceParent) 'Kun' + return Join-Path (Split-Path -Parent $sourceParent) $canonicalLeaf } return '' } @@ -97,69 +145,193 @@ function Resolve-InstallTarget { throw 'The candidate installation path is empty.' } - $legacySourceTarget = Resolve-LegacySourceTarget $source - if (-not [string]::IsNullOrWhiteSpace($legacySourceTarget)) { - return $legacySourceTarget + $candidateIsExplicit = [string]::Equals( + (Get-EnvironmentValue 'KUN_INSTALLER_CANDIDATE_EXPLICIT'), + '1', + [StringComparison]::Ordinal + ) + if (-not $candidateIsExplicit) { + $legacySourceTarget = Resolve-LegacySourceTarget $source + if (-not [string]::IsNullOrWhiteSpace($legacySourceTarget)) { + return $legacySourceTarget + } } + $canonicalLeaf = Get-CanonicalLeaf $leaf = Split-Path -Leaf $candidate $parent = Split-Path -Parent $candidate - if ([string]::Equals($leaf, 'Kun', [StringComparison]::OrdinalIgnoreCase)) { + if ([string]::Equals($leaf, $canonicalLeaf, [StringComparison]::OrdinalIgnoreCase)) { $parentLeaf = Split-Path -Leaf $parent if (Test-LegacyLeaf $parentLeaf) { - return Join-Path (Split-Path -Parent $parent) 'Kun' + return Join-Path (Split-Path -Parent $parent) $canonicalLeaf } return $candidate } if (Test-LegacyLeaf $leaf) { - return Join-Path $parent 'Kun' + return Join-Path $parent $canonicalLeaf } if (-not [string]::IsNullOrWhiteSpace($source) -and (Test-PathEqual $source $candidate)) { return $candidate } - return Join-Path $candidate 'Kun' + return Join-Path $candidate $canonicalLeaf } -function Resolve-RegisteredInstallSource { - $source = Normalize-FullPath (Get-EnvironmentValue 'KUN_INSTALLER_SOURCE') +function Try-NormalizeRegisteredPath([string]$PathValue, [string]$Label) { + if ([string]::IsNullOrWhiteSpace($PathValue)) { + return '' + } + try { + return Normalize-FullPath $PathValue + } catch { + Write-InstallerDiagnostic "Ignoring malformed $Label metadata: $($_.Exception.Message)" + return '' + } +} + +function Get-UninstallCommandSource([string]$UninstallCommand, [string]$Label) { + $uninstallSource = '' + if (-not [string]::IsNullOrWhiteSpace($uninstallCommand)) { + $match = [Text.RegularExpressions.Regex]::Match( + $uninstallCommand.Trim(), + '^(?:"(?[^"]+)"|(?.*?\.exe))(?:\s|$)', + [Text.RegularExpressions.RegexOptions]::IgnoreCase + ) + if ($match.Success) { + $uninstaller = Try-NormalizeRegisteredPath $match.Groups['path'].Value "$Label uninstall command" + $leaf = if ([string]::IsNullOrWhiteSpace($uninstaller)) { '' } else { Split-Path -Leaf $uninstaller } + if (Get-AppSpecificUninstallerFiles | Where-Object { + [string]::Equals($_, $leaf, [StringComparison]::OrdinalIgnoreCase) + }) { + $uninstallSource = Split-Path -Parent $uninstaller + } + } + } + return $uninstallSource +} + +function Resolve-RegisteredInstallSourceValues( + [string]$SourceValue, + [string]$UninstallCommand, + [string]$Label +) { + $source = Try-NormalizeRegisteredPath $SourceValue "$Label install location" + $uninstallSource = Get-UninstallCommandSource $UninstallCommand $Label + + $candidates = @() + if (-not [string]::IsNullOrWhiteSpace($source)) { + $candidates += $source + $sourceParent = Split-Path -Parent $source + if ([string]::Equals((Split-Path -Leaf $source), (Get-CanonicalLeaf), [StringComparison]::OrdinalIgnoreCase) -and + (Test-LegacyLeaf (Split-Path -Leaf $sourceParent))) { + $candidates += $sourceParent + } + } + if (-not [string]::IsNullOrWhiteSpace($uninstallSource)) { + $candidates += $uninstallSource + } + + foreach ($candidate in @($candidates | Select-Object -Unique)) { + if (Test-RecoverableApplicationSource $candidate) { + return $candidate + } + } + + # Keep an unverified registered path so Prepare can distinguish an empty or + # missing stale registration from a non-empty directory that must fail closed. if (-not [string]::IsNullOrWhiteSpace($source)) { return $source } + if (-not [string]::IsNullOrWhiteSpace($uninstallSource)) { + return $uninstallSource + } + if (-not [string]::IsNullOrWhiteSpace($SourceValue) -or + -not [string]::IsNullOrWhiteSpace($UninstallCommand)) { + throw "The $Label registration contains no valid absolute Kun program directory." + } + return '' +} - $uninstallCommand = Get-EnvironmentValue 'KUN_INSTALLER_UNINSTALL_STRING' - if ([string]::IsNullOrWhiteSpace($uninstallCommand)) { - return '' +function Resolve-RegisteredInstallSource { + return Resolve-RegisteredInstallSourceValues ` + (Get-EnvironmentValue 'KUN_INSTALLER_SOURCE') ` + (Get-EnvironmentValue 'KUN_INSTALLER_UNINSTALL_STRING') ` + 'selected-scope' +} + +function Resolve-AutomaticUpdateScope { + $runningSource = Normalize-FullPath (Get-EnvironmentValue 'KUN_INSTALLER_UPDATE_SOURCE') + if ([string]::IsNullOrWhiteSpace($runningSource)) { + throw 'The automatic update did not provide its running application directory.' + } + Assert-SafeInstallRoot $runningSource 'Running application' + Assert-RecoverableApplicationSource $runningSource + + $matches = @() + foreach ($candidate in @( + @{ + Scope = 'current' + Source = Get-EnvironmentValue 'KUN_INSTALLER_CURRENT_USER_SOURCE' + Uninstall = Get-EnvironmentValue 'KUN_INSTALLER_CURRENT_USER_UNINSTALL_STRING' + }, + @{ + Scope = 'all' + Source = Get-EnvironmentValue 'KUN_INSTALLER_ALL_USERS_SOURCE' + Uninstall = Get-EnvironmentValue 'KUN_INSTALLER_ALL_USERS_UNINSTALL_STRING' + } + )) { + try { + $registeredSource = Resolve-RegisteredInstallSourceValues ` + ([string]$candidate.Source) ([string]$candidate.Uninstall) ($candidate.Scope + '-user') + if (-not [string]::IsNullOrWhiteSpace($registeredSource) -and + (Test-PathEqual $registeredSource $runningSource) -and + (Test-RecoverableApplicationSource $registeredSource)) { + $matches += $candidate.Scope + } + } catch { + Write-InstallerDiagnostic "Automatic update ignored invalid $($candidate.Scope) registration: $($_.Exception.Message)" + } } - $match = [Text.RegularExpressions.Regex]::Match( - $uninstallCommand.Trim(), - '^(?:"(?[^"]+)"|(?.*?\.exe))(?:\s|$)', - [Text.RegularExpressions.RegexOptions]::IgnoreCase - ) - if (-not $match.Success) { - return '' + if ($matches.Count -ne 1) { + throw "The automatic update source did not match exactly one verified Kun registration: $runningSource" } + return $matches[0] +} - $uninstaller = Normalize-FullPath $match.Groups['path'].Value - $leaf = Split-Path -Leaf $uninstaller - if (-not $leaf.StartsWith('Uninstall ', [StringComparison]::OrdinalIgnoreCase) -and - -not [string]::Equals($leaf, 'old-uninstaller.exe', [StringComparison]::OrdinalIgnoreCase)) { +function Resolve-TrustedAppUninstaller { + $source = Normalize-FullPath (Get-EnvironmentValue 'KUN_INSTALLER_SOURCE') + if ([string]::IsNullOrWhiteSpace($source)) { return '' } - return Split-Path -Parent $uninstaller + Assert-SafeInstallRoot $source 'Uninstaller source' + Assert-RecoverableApplicationSource $source + foreach ($name in (Get-AppSpecificUninstallerFiles)) { + $candidate = Join-Path $source $name + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + if (Test-ReparsePoint $candidate) { + throw "The app-specific uninstaller is a reparse point: $candidate" + } + return Normalize-FullPath $candidate + } + } + return '' } -function Write-ResolvedInstallTarget([string]$Target) { +function Write-InstallerResult([string]$Value) { $resultPath = Normalize-FullPath $ResultPath if ([string]::IsNullOrWhiteSpace($resultPath)) { $resultPath = Join-Path $PSScriptRoot 'kun-windows-installer-result.txt' } - [IO.File]::WriteAllBytes($resultPath, [Text.Encoding]::Unicode.GetBytes($Target)) - [Console]::Out.Write($Target) + [IO.File]::WriteAllBytes($resultPath, [Text.Encoding]::Unicode.GetBytes($Value)) + [Console]::Out.Write($Value) +} + +function Write-ResolvedInstallTarget([string]$Target) { + Write-InstallerResult $Target } function Get-JournalPath { @@ -170,14 +342,196 @@ function Get-JournalPath { return $journalPath } -function Write-Journal([hashtable]$Journal) { +function Get-NormalizedInstallMode { + $mode = (Get-EnvironmentValue 'KUN_INSTALLER_INSTALL_MODE').Trim() + if ([string]::Equals($mode, 'all', [StringComparison]::OrdinalIgnoreCase)) { + return 'all' + } + if ([string]::Equals($mode, 'CurrentUser', [StringComparison]::OrdinalIgnoreCase) -or + [string]::Equals($mode, 'current', [StringComparison]::OrdinalIgnoreCase)) { + return 'current' + } + throw "KUN_INSTALLER_INSTALL_MODE is invalid: $mode" +} + +function Get-JournalAppGuid { + $appGuid = (Get-EnvironmentValue 'KUN_INSTALLER_APP_GUID').Trim() + if ([string]::IsNullOrWhiteSpace($appGuid)) { + throw 'KUN_INSTALLER_APP_GUID is required for recovery journal actions.' + } + return $appGuid +} + +function Get-JournalTarget { + $target = Normalize-FullPath (Get-EnvironmentValue 'KUN_INSTALLER_TARGET') + if ([string]::IsNullOrWhiteSpace($target)) { + throw 'KUN_INSTALLER_TARGET is required for recovery journal actions.' + } + return $target +} + +function Get-JournalAclOwnerSid([string]$Mode) { + if ($Mode -eq 'all') { + return [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + } + return [Security.Principal.WindowsIdentity]::GetCurrent().User +} + +function Convert-IdentityToSid([string]$Identity) { + if ($Identity -match '^S-\d(?:-\d+)+$') { + return [Security.Principal.SecurityIdentifier]::new($Identity) + } + return [Security.Principal.NTAccount]::new($Identity).Translate( + [Security.Principal.SecurityIdentifier] + ) +} + +function Test-JournalAclSecure([string]$PathValue, [string]$Mode) { + try { + if (Test-ReparsePoint $PathValue) { + return $false + } + $security = Get-Acl -LiteralPath $PathValue + $owner = Convert-IdentityToSid $security.Owner + $expectedOwner = Get-JournalAclOwnerSid $Mode + $systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') + if (-not $owner.Equals($expectedOwner) -and -not $owner.Equals($systemSid)) { + return $false + } + + $dangerousSids = @('S-1-1-0', 'S-1-5-11', 'S-1-5-32-545') + $writeRights = [Security.AccessControl.FileSystemRights]::Write -bor + [Security.AccessControl.FileSystemRights]::Modify -bor + [Security.AccessControl.FileSystemRights]::FullControl -bor + [Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [Security.AccessControl.FileSystemRights]::TakeOwnership + foreach ($rule in $security.GetAccessRules( + $true, + $true, + [Security.Principal.SecurityIdentifier] + )) { + if ($rule.AccessControlType -eq [Security.AccessControl.AccessControlType]::Allow -and + $dangerousSids -contains $rule.IdentityReference.Value -and + (($rule.FileSystemRights -band $writeRights) -ne 0)) { + return $false + } + } + return $true + } catch { + Write-InstallerDiagnostic "Recovery journal ACL validation failed for $PathValue: $($_.Exception.Message)" + return $false + } +} + +function Set-SecureJournalDirectoryAcl([string]$Directory, [string]$Mode) { + $ownerSid = Get-JournalAclOwnerSid $Mode + $administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $security = [Security.AccessControl.DirectorySecurity]::new() + $security.SetAccessRuleProtection($true, $false) + $security.SetOwner($ownerSid) + $inheritance = [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor + [Security.AccessControl.InheritanceFlags]::ObjectInherit + $propagation = [Security.AccessControl.PropagationFlags]::None + foreach ($sid in @($ownerSid, $administratorsSid, $systemSid) | Select-Object -Unique) { + $rule = [Security.AccessControl.FileSystemAccessRule]::new( + $sid, + [Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, + $propagation, + [Security.AccessControl.AccessControlType]::Allow + ) + [void]$security.AddAccessRule($rule) + } + Set-Acl -LiteralPath $Directory -AclObject $security +} + +function Set-SecureJournalFileAcl([string]$PathValue, [string]$Mode) { + $ownerSid = Get-JournalAclOwnerSid $Mode + $administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $security = [Security.AccessControl.FileSecurity]::new() + $security.SetAccessRuleProtection($true, $false) + $security.SetOwner($ownerSid) + foreach ($sid in @($ownerSid, $administratorsSid, $systemSid) | Select-Object -Unique) { + $rule = [Security.AccessControl.FileSystemAccessRule]::new( + $sid, + [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.AccessControlType]::Allow + ) + [void]$security.AddAccessRule($rule) + } + Set-Acl -LiteralPath $PathValue -AclObject $security +} + +function Assert-JournalStorageTrusted { $journalPath = Get-JournalPath $journalParent = Split-Path -Parent $journalPath - [IO.Directory]::CreateDirectory($journalParent) | Out-Null + $mode = Get-NormalizedInstallMode + $journalExists = Test-Path -LiteralPath $journalPath -PathType Leaf + + if (Test-Path -LiteralPath $journalParent) { + if (-not (Test-Path -LiteralPath $journalParent -PathType Container) -or + (Test-ReparsePoint $journalParent)) { + throw "The recovery journal directory is not a trusted directory: $journalParent" + } + if ($journalExists -and -not (Test-JournalAclSecure $journalParent $mode)) { + throw "The existing recovery journal directory has an untrusted ACL: $journalParent" + } + } else { + [IO.Directory]::CreateDirectory($journalParent) | Out-Null + } + + if (-not (Test-JournalAclSecure $journalParent $mode)) { + Set-SecureJournalDirectoryAcl $journalParent $mode + } + if (-not (Test-JournalAclSecure $journalParent $mode)) { + throw "The recovery journal directory ACL could not be secured: $journalParent" + } + + if ($journalExists) { + if (Test-ReparsePoint $journalPath) { + throw "The recovery journal is a reparse point: $journalPath" + } + if (-not (Test-JournalAclSecure $journalPath $mode)) { + throw "The recovery journal file has an untrusted ACL: $journalPath" + } + } +} + +function Assert-JournalContext($Journal) { + $expectedGuid = Get-JournalAppGuid + $expectedMode = Get-NormalizedInstallMode + $expectedTarget = Get-JournalTarget + if ($null -eq $Journal.PSObject.Properties['AppGuid'] -or + -not [string]::Equals([string]$Journal.AppGuid, $expectedGuid, [StringComparison]::OrdinalIgnoreCase)) { + throw 'The recovery journal application identity does not match this installer.' + } + if ($null -eq $Journal.PSObject.Properties['InstallMode'] -or + -not [string]::Equals([string]$Journal.InstallMode, $expectedMode, [StringComparison]::Ordinal)) { + throw 'The recovery journal installation mode does not match this installer transaction.' + } + if ($null -eq $Journal.PSObject.Properties['Target'] -or + -not (Test-PathEqual ([string]$Journal.Target) $expectedTarget)) { + throw 'The recovery journal target does not match this installer transaction.' + } +} + +function Write-Journal([hashtable]$Journal) { + Assert-JournalStorageTrusted + $journalPath = Get-JournalPath $temporaryPath = "$journalPath.tmp" + if (Test-Path -LiteralPath $temporaryPath) { + Remove-Item -LiteralPath $temporaryPath -Force + } + $Journal.SchemaVersion = 3 + $Journal.AppGuid = Get-JournalAppGuid + $Journal.InstallMode = Get-NormalizedInstallMode + $Journal.Target = Get-JournalTarget $Journal.UpdatedAt = [DateTime]::UtcNow.ToString('o') $Journal | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $temporaryPath -Encoding UTF8 Move-Item -LiteralPath $temporaryPath -Destination $journalPath -Force + Set-SecureJournalFileAcl $journalPath (Get-NormalizedInstallMode) } function Read-Journal { @@ -185,12 +539,16 @@ function Read-Journal { if (-not (Test-Path -LiteralPath $journalPath -PathType Leaf)) { return $null } - return Get-Content -LiteralPath $journalPath -Raw | ConvertFrom-Json + Assert-JournalStorageTrusted + $journal = Get-Content -LiteralPath $journalPath -Raw | ConvertFrom-Json + Assert-JournalContext $journal + return $journal } function Remove-Journal { $journalPath = Get-JournalPath if (Test-Path -LiteralPath $journalPath) { + Assert-JournalStorageTrusted Remove-Item -LiteralPath $journalPath -Force } $parent = Split-Path -Parent $journalPath @@ -310,16 +668,63 @@ function Assert-SafeInstallRoot([string]$PathValue, [string]$Label) { Assert-NoReparsePathComponents $normalized $Label } +function Assert-TargetVolumeReadyAndWritable([string]$Target) { + $targetPath = Normalize-FullPath $Target + $root = [IO.Path]::GetPathRoot($targetPath) + if ([string]::IsNullOrWhiteSpace($root) -or -not (Test-Path -LiteralPath $root -PathType Container)) { + throw "The target volume is unavailable: $root" + } + + if ($root -match '^[A-Za-z]:\\$') { + try { + $drive = [IO.DriveInfo]::new($root) + if (-not $drive.IsReady) { + throw "The target volume is not ready: $root" + } + } catch { + throw "The target volume is not ready: $root. $($_.Exception.Message)" + } + } + + $probeDirectory = $targetPath + while (-not (Test-Path -LiteralPath $probeDirectory)) { + $parent = Split-Path -Parent $probeDirectory + if ([string]::IsNullOrWhiteSpace($parent) -or (Test-PathEqual $parent $probeDirectory)) { + throw "No existing target directory is available for a write probe: $targetPath" + } + $probeDirectory = $parent + } + if (-not (Test-Path -LiteralPath $probeDirectory -PathType Container)) { + throw "The nearest existing target ancestor is not a directory: $probeDirectory" + } + + $probePath = Join-Path $probeDirectory ('.kun-installer-write-probe-' + [Guid]::NewGuid().ToString('N') + '.tmp') + try { + $stream = [IO.File]::Open( + $probePath, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::None + ) + $stream.Dispose() + Remove-Item -LiteralPath $probePath -Force + } catch { + if (Test-Path -LiteralPath $probePath) { + Remove-Item -LiteralPath $probePath -Force -ErrorAction SilentlyContinue + } + throw "The target directory is not writable: $probeDirectory. $($_.Exception.Message)" + } +} + function Test-KnownApplicationEntry([IO.FileSystemInfo]$Entry) { if ($Entry.PSIsContainer) { return @('resources', 'locales', 'bin') -contains $Entry.Name.ToLowerInvariant() } - $knownFiles = @( - 'kun.exe', - 'deepseek gui.exe', - 'uninstall kun.exe', - 'uninstall deepseek gui.exe', + $knownFiles = @(@( + Get-ApplicationIdentityFiles + Get-AppSpecificUninstallerFiles + ) | ForEach-Object { $_.ToLowerInvariant() }) + @( 'uninstallericon.ico', 'chrome_100_percent.pak', 'chrome_200_percent.pak', @@ -392,7 +797,10 @@ function Test-AppOwnedProcessPath([string]$ExecutablePath, [string[]]$Roots) { } $relativeLower = $relative.ToLowerInvariant() - if ($relativeLower -eq 'kun.exe' -or $relativeLower -eq 'deepseek gui.exe' -or + $identityMatch = Get-ApplicationIdentityFiles | Where-Object { + [string]::Equals($_, $relative, [StringComparison]::OrdinalIgnoreCase) + } + if ($identityMatch -or $relativeLower.StartsWith('resources\') -or $relativeLower.StartsWith('bin\')) { return $true } @@ -436,28 +844,94 @@ function Stop-InstallRootProcesses { Stop-AppProcesses @($root) } -function Assert-ApplicationSourceIdentity([string]$Source) { - $identityFiles = @('Kun.exe', 'DeepSeek GUI.exe') - if (-not ($identityFiles | Where-Object { +function Test-ApplicationSourceIdentity([string]$Source) { + if ([string]::IsNullOrWhiteSpace($Source) -or + -not (Test-Path -LiteralPath $Source -PathType Container)) { + return $false + } + $identityFiles = Get-ApplicationIdentityFiles + return [bool]($identityFiles | Where-Object { Test-Path -LiteralPath (Join-Path $Source $_) -PathType Leaf - })) { + }) +} + +function Assert-ApplicationSourceIdentity([string]$Source) { + if (-not (Test-ApplicationSourceIdentity $Source)) { throw "The registered source has no application identity executable: $Source" } } +function Test-PackagedApplicationPayload([string]$Source) { + if ([string]::IsNullOrWhiteSpace($Source)) { + return $false + } + $packagedPayload = Join-Path (Join-Path $Source 'resources') 'app.asar' + return (Test-Path -LiteralPath $packagedPayload -PathType Leaf) +} + +function Assert-PackagedApplicationPayload([string]$Source) { + if (-not (Test-PackagedApplicationPayload $Source)) { + throw "The external current-user installation source is not a recognized packaged Kun installation: $Source" + } +} + +function Test-AppSpecificUninstaller([string]$Source) { + if ([string]::IsNullOrWhiteSpace($Source)) { + return $false + } + return [bool](Get-AppSpecificUninstallerFiles | Where-Object { + Test-Path -LiteralPath (Join-Path $Source $_) -PathType Leaf + }) +} + +function Test-RecoverableApplicationSource([string]$Source) { + if (Test-ApplicationSourceIdentity $Source) { + return $true + } + return (Test-AppSpecificUninstaller $Source) -and (Test-PackagedApplicationPayload $Source) +} + +function Assert-RecoverableApplicationSource([string]$Source) { + if (-not (Test-RecoverableApplicationSource $Source)) { + throw ( + "The registered source contains files but is not a verifiable Kun installation: $Source. " + + 'No files or registration were changed.' + ) + } +} + function Assert-TrustedSecondarySource([string]$Source) { $profile = Normalize-FullPath $env:USERPROFILE - if ([string]::IsNullOrWhiteSpace($profile) -or -not (Test-PathWithin $Source $profile) -or - (Test-PathEqual $Source $profile)) { - throw "The current-user installation source is outside the current user profile: $Source" + if (-not [string]::IsNullOrWhiteSpace($profile) -and (Test-PathWithin $Source $profile) -and + -not (Test-PathEqual $Source $profile)) { + return } + + Assert-SafeInstallRoot $Source 'External current-user installation source' + if (@(Get-ChildItem -LiteralPath $Source -Force).Count -eq 0) { + return + } + Assert-RecoverableApplicationSource $Source + Assert-PackagedApplicationPayload $Source } -function Get-InstallSources { +function Get-InstallSources( + [bool]$ValidateSecondary = $true, + [bool]$IncludeMissingSecondary = $false +) { $primary = Normalize-FullPath (Get-EnvironmentValue 'KUN_INSTALLER_SOURCE') $secondary = Normalize-FullPath (Get-EnvironmentValue 'KUN_INSTALLER_SECONDARY_SOURCE') if (-not [string]::IsNullOrWhiteSpace($secondary)) { - Assert-TrustedSecondarySource $secondary + if (-not (Test-Path -LiteralPath $secondary)) { + Write-InstallerDiagnostic "Ignoring missing current-user installation source: $secondary" + if (-not $IncludeMissingSecondary) { + $secondary = '' + } + } elseif (-not (Test-Path -LiteralPath $secondary -PathType Container)) { + throw "The current-user installation source exists but is not a directory: $secondary" + } elseif ($ValidateSecondary) { + Assert-TrustedSecondarySource $secondary + } } $sources = @($primary, $secondary) $normalizedSources = @() @@ -495,6 +969,9 @@ function Get-ValidatedJournalRecord($Record) { Assert-SafeInstallRoot $source 'Journal source' Assert-SafeInstallRoot $target 'Journal target' + if (-not (Test-PathEqual $target (Get-JournalTarget))) { + throw "The preservation journal record target does not match the current transaction: $target" + } if (-not (Test-PathEqual $stash (Get-PreservationRoot $source))) { throw "The preservation journal references an unexpected recovery directory: $stash" } @@ -561,7 +1038,7 @@ function Invoke-RestoreJournal { if ($remainingRecords.Count -gt 0) { $updated = @{ - SchemaVersion = 2 + SchemaVersion = 3 Phase = 'restore-conflict' Records = $remainingRecords } @@ -576,7 +1053,11 @@ function Invoke-RestoreJournal { function Invoke-Prepare { Invoke-RestoreJournal - $sources = @(Get-InstallSources) + $primarySource = Normalize-FullPath (Get-EnvironmentValue 'KUN_INSTALLER_SOURCE') + $secondarySource = Normalize-FullPath (Get-EnvironmentValue 'KUN_INSTALLER_SECONDARY_SOURCE') + $registeredSources = @(Get-InstallSources $true $true) + $sources = @() + [int]$staleSourceMask = 0 $target = Normalize-FullPath (Get-EnvironmentValue 'KUN_INSTALLER_TARGET') if ([string]::IsNullOrWhiteSpace($target)) { throw 'KUN_INSTALLER_TARGET is required.' @@ -586,8 +1067,35 @@ function Invoke-Prepare { if ((Test-Path -LiteralPath $target) -and -not (Test-Path -LiteralPath $target -PathType Container)) { throw "The target exists but is not a directory: $target" } - foreach ($source in $sources) { + Assert-TargetVolumeReadyAndWritable $target + foreach ($source in $registeredSources) { Assert-SafeInstallRoot $source 'Source' + if (-not (Test-Path -LiteralPath $source)) { + if (Test-PathEqual $source $primarySource) { + $staleSourceMask = $staleSourceMask -bor 1 + } + if (Test-PathEqual $source $secondarySource) { + $staleSourceMask = $staleSourceMask -bor 2 + } + continue + } + if (-not (Test-Path -LiteralPath $source -PathType Container)) { + throw "The registered source exists but is not a directory: $source" + } + + $entries = @(Get-ChildItem -LiteralPath $source -Force) + if ($entries.Count -eq 0) { + if (Test-PathEqual $source $primarySource) { + $staleSourceMask = $staleSourceMask -bor 1 + } + if (Test-PathEqual $source $secondarySource) { + $staleSourceMask = $staleSourceMask -bor 2 + } + continue + } + + Assert-RecoverableApplicationSource $source + $sources += $source } $targetIsSource = $sources | Where-Object { Test-PathEqual $_ $target } @@ -600,14 +1108,7 @@ function Invoke-Prepare { $preparedSources = @() foreach ($source in $sources) { - if (-not (Test-Path -LiteralPath $source -PathType Container)) { - continue - } - Assert-ApplicationSourceIdentity $source $entries = @(Get-ChildItem -LiteralPath $source -Force) - if ($entries.Count -eq 0) { - continue - } if (-not ($entries | Where-Object { Test-KnownApplicationEntry $_ })) { throw "The registered source has no recognized application payload: $source" } @@ -634,7 +1135,7 @@ function Invoke-Prepare { Stop-AppProcesses @($sources + $target) $journal = @{ - SchemaVersion = 2 + SchemaVersion = 3 Phase = 'preserving' Records = @() } @@ -664,6 +1165,7 @@ function Invoke-Prepare { if ($journal.Records.Count -gt 0) { Write-Journal $journal } + Write-InstallerResult ([string]$staleSourceMask) } function Assert-FallbackCleanupSource([string]$Source) { @@ -712,7 +1214,25 @@ function Invoke-FallbackCleanup { function Remove-EmptyLegacyContainers { $candidates = @() - foreach ($source in @(Get-InstallSources)) { + $primary = Normalize-FullPath (Get-EnvironmentValue 'KUN_INSTALLER_SOURCE') + $secondary = Normalize-FullPath (Get-EnvironmentValue 'KUN_INSTALLER_SECONDARY_SOURCE') + $primaryIsStale = [string]::Equals( + (Get-EnvironmentValue 'KUN_INSTALLER_PRIMARY_SOURCE_STALE'), + '1', + [StringComparison]::Ordinal + ) + $secondaryIsStale = [string]::Equals( + (Get-EnvironmentValue 'KUN_INSTALLER_SECONDARY_SOURCE_STALE'), + '1', + [StringComparison]::Ordinal + ) + # Prepare performs the positive secondary-source validation before cleanup. + # Restore can run after the packaged payload has already been removed. + foreach ($source in @(Get-InstallSources $false)) { + if (($primaryIsStale -and (Test-PathEqual $source $primary)) -or + ($secondaryIsStale -and (Test-PathEqual $source $secondary))) { + continue + } $candidates += $source $parent = Split-Path -Parent $source if (Test-LegacyLeaf (Split-Path -Leaf $parent)) { @@ -730,13 +1250,24 @@ function Remove-EmptyLegacyContainers { } function Update-UserPath { - $sources = @(Get-InstallSources) + # Missing secondary sources do not participate in filesystem migration, but + # their stale bin entries should still be removed from the user PATH. + $sources = @(Get-InstallSources $false $true) $target = Normalize-FullPath (Get-EnvironmentValue 'KUN_INSTALLER_TARGET') if ([string]::IsNullOrWhiteSpace($target)) { throw 'KUN_INSTALLER_TARGET is required for PATH reconciliation.' } - $sourceBins = @($sources | ForEach-Object { Join-Path $_ 'bin' }) + $pathSources = @() + foreach ($source in $sources) { + $pathSources += $source + if (Test-LegacyLeaf (Split-Path -Leaf $source)) { + # Older assisted installers could register or add PATH for the falsely + # nested child even though the application payload lived in the parent. + $pathSources += Join-Path $source (Get-CanonicalLeaf) + } + } + $sourceBins = @($pathSources | Select-Object -Unique | ForEach-Object { Join-Path $_ 'bin' }) $targetBin = Join-Path $target 'bin' $current = [Environment]::GetEnvironmentVariable('Path', 'User') $parts = @($current -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) @@ -764,6 +1295,12 @@ try { 'ResolveSource' { Write-ResolvedInstallTarget (Resolve-RegisteredInstallSource) } + 'ResolveUpdateScope' { + Write-InstallerResult (Resolve-AutomaticUpdateScope) + } + 'ResolveUninstaller' { + Write-InstallerResult (Resolve-TrustedAppUninstaller) + } 'StopProcesses' { Stop-InstallRootProcesses } diff --git a/kun/src/adapters/model/compat-model-client.endpoint-format.test.ts b/kun/src/adapters/model/compat-model-client.endpoint-format.test.ts index 5838534df..22da36313 100644 --- a/kun/src/adapters/model/compat-model-client.endpoint-format.test.ts +++ b/kun/src/adapters/model/compat-model-client.endpoint-format.test.ts @@ -337,12 +337,17 @@ describe('CompatModelClient per-model endpointFormat', () => { } }) - it('uses stable thread-scoped prompt cache keys only for Codex Responses', () => { + it('uses stable thread-scoped prompt cache keys for Codex and GPT-5.6 Responses', () => { const codecs = createCompatRequestCodecs() - const buildResponses = (threadId: string, isCodex: boolean, isCodexLite = false) => + const buildResponses = ( + model: string, + threadId: string, + isCodex: boolean, + isCodexLite = false + ) => codecs.build({ - request: { ...request('gpt-5.6-sol'), threadId }, - model: 'gpt-5.6-sol', + request: { ...request(model), threadId }, + model, messages: [], tools: [], stream: true, @@ -355,18 +360,50 @@ describe('CompatModelClient per-model endpointFormat', () => { codexNativeImageGeneration: false }) - const first = buildResponses('thread-a', true) - const repeated = buildResponses('thread-a', true) - const isolated = buildResponses('thread-b', true) - const lite = buildResponses('thread-a', true, true) - const compatible = buildResponses('thread-a', false) + const first = buildResponses('gpt-5.5-codex', 'thread-a', true) + const repeated = buildResponses('gpt-5.5-codex', 'thread-a', true) + const isolated = buildResponses('gpt-5.5-codex', 'thread-b', true) + const lite = buildResponses('gpt-5.6-sol', 'thread-a', true, true) + const compatibleFirst = buildResponses('gpt-5.6-sol', 'thread-a', false) + const compatibleRepeated = buildResponses('gpt-5.6-sol', 'thread-a', false) + const compatibleIsolated = buildResponses('gpt-5.6-sol', 'thread-b', false) + const providerQualified = buildResponses('openai/gpt-5.6-terra', 'thread-a', false) expect(first.prompt_cache_key).toBe('thread-a') expect(repeated.prompt_cache_key).toBe(first.prompt_cache_key) expect(isolated.prompt_cache_key).toBe('thread-b') expect(isolated.prompt_cache_key).not.toBe(first.prompt_cache_key) expect(lite.prompt_cache_key).toBe('thread-a') - expect(compatible).not.toHaveProperty('prompt_cache_key') + expect(compatibleFirst.prompt_cache_key).toBe('thread-a') + expect(compatibleRepeated.prompt_cache_key).toBe(compatibleFirst.prompt_cache_key) + expect(compatibleIsolated.prompt_cache_key).toBe('thread-b') + expect(compatibleIsolated.prompt_cache_key).not.toBe(compatibleFirst.prompt_cache_key) + expect(providerQualified.prompt_cache_key).toBe('thread-a') + }) + + it('keeps custom prompt cache routing scoped to GPT-5.6 Responses requests', () => { + const codecs = createCompatRequestCodecs() + const build = ( + model: string, + endpointFormat: 'chat_completions' | 'responses' | 'messages' + ) => codecs.build({ + request: request(model), + model, + messages: [], + tools: [], + stream: true, + endpointFormat, + baseUrl: 'https://provider.example/v1', + isCodex: false, + isCodexLite: false, + codexNativeImageGeneration: false + }) + + expect(build('gpt-5.6-sol', 'responses').prompt_cache_key).toBe('t1') + expect(build('gpt-5.6-sol', 'chat_completions')).not.toHaveProperty('prompt_cache_key') + expect(build('gpt-5.6-sol', 'messages')).not.toHaveProperty('prompt_cache_key') + expect(build('gpt-5.5-codex', 'responses')).not.toHaveProperty('prompt_cache_key') + expect(build('gpt-5.60-preview', 'responses')).not.toHaveProperty('prompt_cache_key') }) it('routes an override model to the Anthropic Messages endpoint while others use chat completions', async () => { diff --git a/kun/src/adapters/model/compat-request-codecs.ts b/kun/src/adapters/model/compat-request-codecs.ts index c750a4ca1..abf313ea2 100644 --- a/kun/src/adapters/model/compat-request-codecs.ts +++ b/kun/src/adapters/model/compat-request-codecs.ts @@ -80,6 +80,12 @@ export type CompatRequestCodecDeps = { const DEFAULT_MESSAGES_MAX_TOKENS = 8_192 const DEFAULT_MESSAGES_REASONING_MAX_TOKENS = 32_768 +function isGpt56FamilyModel(model: string): boolean { + const normalized = model.trim().toLowerCase() + const basename = normalized.split('/').at(-1) ?? normalized + return /^gpt-5\.6(?:-|$)/.test(basename) +} + export class CompatRequestCodecs { constructor(private readonly deps: CompatRequestCodecDeps) {} @@ -181,6 +187,7 @@ export class CompatRequestCodecs { ] : [] const requiredToolChoice = namedToolChoice(input) + const useThreadPromptCacheKey = input.isCodex || isGpt56FamilyModel(input.model) const body: Record = { model: input.model, stream: input.stream, @@ -194,7 +201,7 @@ export class CompatRequestCodecs { parallel_tool_calls: false } : input.isCodex ? { instructions: instructions || ' ', store: false } : {}), - ...(input.isCodex ? { prompt_cache_key: input.request.threadId } : {}) + ...(useThreadPromptCacheKey ? { prompt_cache_key: input.request.threadId } : {}) } if (input.maxTokens !== undefined && !input.isCodex) body.max_output_tokens = input.maxTokens if ( diff --git a/kun/src/cli/serve-entry.ts b/kun/src/cli/serve-entry.ts index cac7b23d8..95c6618b2 100644 --- a/kun/src/cli/serve-entry.ts +++ b/kun/src/cli/serve-entry.ts @@ -15,7 +15,7 @@ import { } from '../server/event-loop-monitor.js' import { installServeCrashHandlers } from './serve-crash-handlers.js' import { runExtensionCommand } from './extension-cli.js' -import { resolveSharedRuntime, runRuntimeCommand } from './shared-runtime.js' +import { inspectSharedRuntime, resolveSharedRuntime, runRuntimeCommand } from './shared-runtime.js' import { withRuntimeStartLock } from '../server/runtime-discovery.js' import { RuntimeBuildIdSchema } from '../contracts/runtime-info.js' import { readRuntimeBuildIdForEntry } from '../server/runtime-build-identity.js' @@ -25,6 +25,7 @@ import { RuntimeFlavorSchema } from '../contracts/runtime-flavor.js' import { runtimeDiscoveryDirectory } from './shared-runtime.js' import { defaultKunControlDir } from '../manager/manager-discovery.js' import { + ManagerRuntimeSlotBusyError, registerRuntimeWithManager, ensureServiceManager, resolveServiceManager, @@ -91,11 +92,19 @@ async function serveMain(argv: readonly string[]): Promise { { kind: 'existing'; existing: NonNullable>> } | { kind: 'started'; server: KunServeHandle } > => { - const existing = await resolveSharedRuntime(parsed.options.dataDir, fetch, { + const inspected = await inspectSharedRuntime(parsed.options.dataDir, fetch, { runtimeFlavor, - controlDir + controlDir, + manager }) + const existing = inspected?.connection ?? null if (existing) return { kind: 'existing', existing } + if (inspected) { + throw new Error( + `Kun shared runtime process ${inspected.discovery.pid} is still alive but is not responding; ` + + 'preserving the manager owner instead of starting a second runtime' + ) + } return { kind: 'started', server: await startKunServe({ @@ -142,6 +151,20 @@ async function serveMain(argv: readonly string[]): Promise { ...(buildId ? { buildId } : {}), ...(process.env.KUN_RUNTIME_LOG_PATH ? { logPath: process.env.KUN_RUNTIME_LOG_PATH } : {}) } + let ownershipShutdownSignaled = false + let requestOwnershipShutdown!: () => void + const ownershipShutdownRequested = new Promise((resolve) => { + requestOwnershipShutdown = resolve + }) + const signalOwnershipShutdown = (ownerInstanceId?: string): void => { + if (ownershipShutdownSignaled) return + ownershipShutdownSignaled = true + process.stderr.write( + `kun serve: ${runtimeFlavor} runtime registration is owned by ` + + `${ownerInstanceId ?? 'another instance'}; stopping duplicate PID ${process.pid}.\n` + ) + requestOwnershipShutdown() + } let managerRecovery: Promise | null = null const applyManagerConnection = (connection: typeof manager): void => { manager.discovery = connection.discovery @@ -171,12 +194,27 @@ async function serveMain(argv: readonly string[]): Promise { flavor: runtimeFlavor, instanceId: server.instanceId }) - if (!accepted) throw new Error('runtime registration is no longer current') - } catch { + if (!accepted) { + signalOwnershipShutdown() + return + } + } catch (error) { + if (error instanceof ManagerRuntimeSlotBusyError) { + signalOwnershipShutdown(error.owner.instanceId) + return + } if (!managerRecovery) { managerRecovery = recoverManagerConnection().finally(() => { managerRecovery = null }) } - await managerRecovery + try { + await managerRecovery + } catch (recoveryError) { + if (recoveryError instanceof ManagerRuntimeSlotBusyError) { + signalOwnershipShutdown(recoveryError.owner.instanceId) + return + } + throw recoveryError + } } } try { @@ -222,15 +260,23 @@ async function serveMain(argv: readonly string[]): Promise { stopping = true if (managerHeartbeat) clearInterval(managerHeartbeat) loopMonitor.stop() - void unregisterRuntimeWithManager({ + // Keep the manager slot owned until the server has closed its stores and + // released filesystem handles. A concurrent client must not elect a + // replacement in the gap between unregister and process teardown. + void server.close().finally(() => unregisterRuntimeWithManager({ manager, flavor: runtimeFlavor, instanceId: server.instanceId - }).finally(() => server.close()).finally(resolve) + })).catch((error) => { + process.stderr.write( + `kun serve: failed to close runtime cleanly: ${error instanceof Error ? error.message : String(error)}\n` + ) + }).finally(resolve) } process.once('SIGTERM', stop) process.once('SIGINT', stop) void server.shutdownRequested.then(stop) + void ownershipShutdownRequested.then(stop) }) return ServeExitCode.ok } diff --git a/kun/src/cli/shared-runtime.test.ts b/kun/src/cli/shared-runtime.test.ts index 9423ccd6c..69baef504 100644 --- a/kun/src/cli/shared-runtime.test.ts +++ b/kun/src/cli/shared-runtime.test.ts @@ -7,11 +7,13 @@ import { buildRuntimeCapabilityManifest } from '../contracts/capabilities.js' import { modelCapabilitiesForModel } from '../loop/model-context-profile.js' import { ensureSharedRuntime, + inspectSharedRuntime, probeRuntimeDiscovery, resolveSharedRuntime, runRuntimeCommand, stopSharedRuntime } from './shared-runtime.js' +import type { ServiceManagerConnection } from '../manager/manager-client.js' function record(overrides: Partial = {}): RuntimeDiscoveryRecord { return { @@ -30,6 +32,25 @@ function record(overrides: Partial = {}): RuntimeDiscove } } +function managerConnection(dataDir: string): ServiceManagerConnection { + return { + discovery: { + version: 1, + protocolVersion: 1, + instanceId: 'manager-a', + pid: process.pid, + startedAt: '2026-07-22T00:00:00.000Z', + host: '127.0.0.1', + port: 18700, + baseUrl: 'http://127.0.0.1:18700', + managerToken: 'manager-secret', + serviceVersion: '0.1.0', + dataDir, + settingsPath: join(dataDir, 'kun-settings.json') + } + } +} + describe('shared runtime discovery validation', () => { it('uses the GUI-configured data dir by default while preserving explicit precedence', async () => { const root = await mkdtemp(join(tmpdir(), 'kun-runtime-gui-data-dir-')) @@ -109,6 +130,112 @@ describe('shared runtime discovery validation', () => { } }) + it('reuses a healthy manager owner when filesystem discovery is missing', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'kun-manager-runtime-owner-')) + const buildId = 'a'.repeat(64) + const managed = record({ buildId, flavor: 'production' }) + const manager = managerConnection(dataDir) + const capabilities = buildRuntimeCapabilityManifest({ + model: modelCapabilitiesForModel('fixture') + }) + const fetchMock = vi.fn(async (url: string | URL | Request) => { + const target = String(url) + if (target === `${manager.discovery.baseUrl}/v1/runtimes/production`) { + return Response.json({ registration: { + flavor: 'production', + instanceId: managed.instanceId, + pid: managed.pid, + startedAt: managed.startedAt, + host: managed.host, + port: managed.port, + baseUrl: managed.baseUrl, + runtimeToken: managed.runtimeToken, + buildId + } }) + } + if (target === `${managed.baseUrl}/v1/runtime/info`) { + return Response.json({ + instanceId: managed.instanceId, + serviceVersion: managed.serviceVersion, + buildId, + launchMode: 'shared', + host: managed.host, + port: managed.port, + dataDir, + model: 'fixture', + approvalPolicy: 'on-request', + sandboxMode: 'workspace-write', + insecure: false, + startedAt: managed.startedAt, + pid: managed.pid, + capabilities + }) + } + return new Response('', { status: 404 }) + }) + try { + const connection = await ensureSharedRuntime({ + dataDir, + manager, + expectedBuildId: buildId, + fetch: fetchMock as unknown as typeof fetch, + launch: { + command: process.execPath, + args: ['-e', 'process.exit(99)'], + runAsNode: false + } + }) + + expect(connection.discovery).toMatchObject({ + instanceId: managed.instanceId, + pid: managed.pid, + buildId + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + await expect(readFile(join(dataDir, 'runtime.json'), 'utf8')).rejects.toMatchObject({ + code: 'ENOENT' + }) + } finally { + await rm(dataDir, { recursive: true, force: true }) + } + }) + + it('removes an exact dead manager registration before discovery fallback', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'kun-manager-runtime-stale-')) + const manager = managerConnection(dataDir) + const deadPid = 2_147_483_647 + const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const target = String(url) + if (target === `${manager.discovery.baseUrl}/v1/runtimes/production`) { + return Response.json({ registration: { + flavor: 'production', + instanceId: 'runtime-dead', + pid: deadPid, + startedAt: '2026-07-22T00:00:00.000Z', + host: '127.0.0.1', + port: 18899, + baseUrl: 'http://127.0.0.1:18899', + runtimeToken: 'secret' + } }) + } + if ( + target === `${manager.discovery.baseUrl}/v1/runtimes/production/runtime-dead` && + init?.method === 'DELETE' + ) return Response.json({ removed: true }) + return new Response('', { status: 404 }) + }) + try { + await expect(inspectSharedRuntime( + dataDir, + fetchMock as unknown as typeof fetch, + { runtimeFlavor: 'production', manager } + )).resolves.toBeNull() + expect(fetchMock).toHaveBeenCalledTimes(2) + } finally { + await rm(dataDir, { recursive: true, force: true }) + } + }) + it('defers a build handover while the elected runtime has an active turn', async () => { const dataDir = await mkdtemp(join(tmpdir(), 'kun-shared-runtime-active-build-')) const discovery = record({ buildId: 'a'.repeat(64) }) @@ -188,7 +315,8 @@ const server = http.createServer((req, res) => { } if (req.url === '/v1/runtime/shutdown' && req.method === 'POST') { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ ok: true })); - setTimeout(() => server.close(() => { try { fs.unlinkSync(path.join(dataDir, 'runtime.json')); } catch {} process.exit(0); }), 10); + try { fs.unlinkSync(path.join(dataDir, 'runtime.json')); } catch {} + setTimeout(() => { server.close(); process.exit(0); }, 250); return; } if (req.url === '/crash' && req.method === 'POST') { @@ -248,7 +376,9 @@ server.listen(0, '127.0.0.1', () => { const recovered = await ensureSharedRuntime(launch) expect(recovered.discovery.instanceId).not.toBe(connection.discovery.instanceId) expect(recovered.discovery.pid).not.toBe(connection.discovery.pid) + const stopStartedAt = Date.now() expect(await stopSharedRuntime(dataDir)).toBe(true) + expect(Date.now() - stopStartedAt).toBeGreaterThanOrEqual(150) } finally { await stopSharedRuntime(dataDir).catch(() => undefined) await rm(dataDir, { recursive: true, force: true }) diff --git a/kun/src/cli/shared-runtime.ts b/kun/src/cli/shared-runtime.ts index a5d7865a8..8788fc0f4 100644 --- a/kun/src/cli/shared-runtime.ts +++ b/kun/src/cli/shared-runtime.ts @@ -8,6 +8,7 @@ import { spawn } from 'node:child_process' import { RuntimeInfoResponse, type RuntimeInfoResponse as RuntimeInfo } from '../contracts/runtime-info.js' import { isLoopbackHost } from '../server/loopback-host.js' import { + createRuntimeDiscoveryRecord, type RuntimeDiscoveryRecord, readRuntimeDiscovery, removeRuntimeDiscovery, @@ -20,9 +21,15 @@ import { } from './gui-settings-bridge.js' import { readRuntimeBuildIdForEntry } from '../server/runtime-build-identity.js' import { DEFAULT_FRESH_SERVE_PERMISSIONS } from './cli-options.js' -import type { RuntimeFlavor } from '../contracts/runtime-flavor.js' +import type { RuntimeFlavor, RuntimeRegistration } from '../contracts/runtime-flavor.js' import { defaultKunControlDir } from '../manager/manager-discovery.js' -import type { ServiceManagerConnection } from '../manager/manager-client.js' +import { + readManagerRuntime, + resolveServiceManager, + unregisterRuntimeWithManager, + type ServiceManagerConnection +} from '../manager/manager-client.js' +import { sameCanonicalPath } from '../manager/canonical-path.js' import { resolveCliRuntimeFlavor, runtimeBuildIdForFlavor, @@ -49,6 +56,7 @@ export type SharedRuntimeInspection = { export type SharedRuntimeScope = { runtimeFlavor?: RuntimeFlavor controlDir?: string + manager?: ServiceManagerConnection } export async function runRuntimeCommand( @@ -72,7 +80,6 @@ export async function runRuntimeCommand( const environment = io.env ?? {} const runtimeFlavor = resolveCliRuntimeFlavor({ env: environment }) const runtimeLabel = runtimeDisplayName(runtimeFlavor) - const scope = { runtimeFlavor } const dataDirResult = runtimeDataDir(argv.slice(1), environment) if (!dataDirResult.ok) { io.stderr.write(`kun runtime: ${dataDirResult.message}\n`) @@ -82,6 +89,16 @@ export async function runRuntimeCommand( const guiSettings = await readGuiSharedSettings({ env: environment }) if (dataDirResult.source === 'default' && guiSettings) dataDir = guiSettings.dataDir const fetchImpl = io.fetch ?? fetch + const controlDir = environment.KUN_MANAGER_CONTROL_DIR?.trim() || defaultKunControlDir() + const resolvedManager = await resolveServiceManager(controlDir, fetchImpl).catch(() => null) + const manager = resolvedManager && sameCanonicalPath(resolvedManager.discovery.dataDir, dataDir) + ? resolvedManager + : undefined + const scope: SharedRuntimeScope = { + runtimeFlavor, + controlDir, + ...(manager ? { manager } : {}) + } const unpublishedGuiRuntime = guiSettings && dataDir === guiSettings.dataDir ? await hasUnpublishedGuiRuntime(guiSettings, fetchImpl) : false @@ -125,7 +142,9 @@ export async function runRuntimeCommand( dataDir, env: io.env, fetch: fetchImpl, - runtimeFlavor + runtimeFlavor, + controlDir, + ...(manager ? { manager } : {}) }) io.stdout.write(`${runtimeLabel} restarted at ${restarted.discovery.baseUrl}.\n`) return 0 @@ -193,6 +212,15 @@ export async function inspectSharedRuntime( scope: SharedRuntimeScope = {} ): Promise { const flavor = scope.runtimeFlavor ?? 'production' + if (scope.manager) { + const managed = await inspectManagerRuntime( + dataDir, + scope.manager, + flavor, + fetchImpl + ) + if (managed) return managed + } const discoveryDir = runtimeDiscoveryDirectory(dataDir, flavor, scope.controlDir) const discovery = await readRuntimeDiscovery(discoveryDir, flavor).catch(() => null) if (!discovery || !safeDiscoveryUrl(discovery) || !processAlive(discovery.pid)) { @@ -224,7 +252,7 @@ export async function ensureSharedRuntime(input: { const runtimeFlavor = input.runtimeFlavor ?? resolveCliRuntimeFlavor({ env: input.env ?? process.env }) const controlDir = input.controlDir ?? defaultKunControlDir() const discoveryDir = runtimeDiscoveryDirectory(input.dataDir, runtimeFlavor, controlDir) - const scope = { runtimeFlavor, controlDir } + const scope = { runtimeFlavor, controlDir, ...(input.manager ? { manager: input.manager } : {}) } const sourceBuildId = input.expectedBuildId ?? await readRuntimeBuildIdForEntry(import.meta.url) const expectedBuildId = runtimeBuildIdForFlavor( sourceBuildId, @@ -429,13 +457,16 @@ export async function stopSharedRuntime( ): Promise { const runtimeFlavor = scope.runtimeFlavor ?? 'production' const discoveryDir = runtimeDiscoveryDirectory(dataDir, runtimeFlavor, scope.controlDir) - const record = await readRuntimeDiscovery(discoveryDir, runtimeFlavor).catch(() => null) - if (!record) return false - if (!safeDiscoveryUrl(record) || !processAlive(record.pid)) { - await removeRuntimeDiscovery(discoveryDir, record.instanceId, runtimeFlavor).catch(() => undefined) + const inspected = await inspectSharedRuntime(dataDir, fetchImpl, scope) + if (!inspected) { + const stale = await readRuntimeDiscovery(discoveryDir, runtimeFlavor).catch(() => null) + if (stale && !processAlive(stale.pid)) { + await removeRuntimeDiscovery(discoveryDir, stale.instanceId, runtimeFlavor).catch(() => undefined) + } return false } - const live = await probeRuntimeDiscovery(record, fetchImpl) + const record = inspected.discovery + const live = inspected.connection if (!live) { throw new Error( `Kun shared runtime process ${record.pid} is still alive but did not respond to the shutdown probe; its discovery record was preserved` @@ -453,11 +484,113 @@ export async function stopSharedRuntime( if (!response.ok) throw new Error(`runtime shutdown failed with HTTP ${response.status}`) const deadline = Date.now() + STOP_TIMEOUT_MS while (Date.now() < deadline) { - const current = await readRuntimeDiscovery(discoveryDir, runtimeFlavor).catch(() => null) - if (!current || current.instanceId !== record.instanceId) return true + if (!processAlive(record.pid)) { + await Promise.all([ + removeRuntimeDiscovery(discoveryDir, record.instanceId, runtimeFlavor).catch(() => false), + scope.manager + ? unregisterRuntimeWithManager({ + manager: scope.manager, + flavor: runtimeFlavor, + instanceId: record.instanceId, + fetch: fetchImpl + }) + : Promise.resolve() + ]) + return true + } await delay(POLL_MS) } - throw new Error('timed out waiting for the Kun runtime to stop') + throw new Error(`timed out waiting for Kun runtime process ${record.pid} to exit`) +} + +async function inspectManagerRuntime( + dataDir: string, + manager: ServiceManagerConnection, + flavor: RuntimeFlavor, + fetchImpl: typeof fetch +): Promise { + const registration = await readManagerRuntime(manager, flavor, fetchImpl) + if (!registration) return null + if (!processAlive(registration.pid)) { + await unregisterRuntimeWithManager({ + manager, + flavor, + instanceId: registration.instanceId, + fetch: fetchImpl + }) + return null + } + const fallback = discoveryFromManagerRegistration(registration) + return { + discovery: fallback, + connection: await probeManagerRuntimeRegistration( + dataDir, + registration, + fetchImpl + ) + } +} + +async function probeManagerRuntimeRegistration( + dataDir: string, + registration: RuntimeRegistration, + fetchImpl: typeof fetch +): Promise { + const fallback = discoveryFromManagerRegistration(registration) + if (!safeDiscoveryUrl(fallback) || !processAlive(registration.pid)) return null + try { + const response = await fetchImpl(`${registration.baseUrl.replace(/\/$/u, '')}/v1/runtime/info`, { + headers: registration.runtimeToken + ? { authorization: `Bearer ${registration.runtimeToken}` } + : {}, + signal: AbortSignal.timeout(2_000) + }) + if (!response.ok) return null + const info = RuntimeInfoResponse.parse(await response.json()) + if ( + info.instanceId !== registration.instanceId || + info.pid !== registration.pid || + info.startedAt !== registration.startedAt || + info.buildId !== registration.buildId || + !sameCanonicalPath(info.dataDir, dataDir) + ) return null + const discovery = discoveryFromManagerRegistration(registration, info) + const activeTurnCount = parseActiveTurnCount( + response.headers.get('x-kun-active-turn-count') + ) + const managerProtocolVersion = parsePositiveIntegerHeader( + response.headers.get('x-kun-manager-protocol-version') + ) + return { + discovery, + info, + ...(activeTurnCount !== undefined ? { activeTurnCount } : {}), + ...(managerProtocolVersion !== undefined ? { managerProtocolVersion } : {}) + } + } catch { + return null + } +} + +function discoveryFromManagerRegistration( + registration: RuntimeRegistration, + info?: RuntimeInfo +): RuntimeDiscoveryRecord { + return createRuntimeDiscoveryRecord({ + instanceId: registration.instanceId, + pid: registration.pid, + startedAt: registration.startedAt, + host: registration.host, + port: registration.port, + baseUrl: registration.baseUrl, + runtimeToken: registration.runtimeToken, + insecure: info?.insecure ?? false, + ...(info ? { serviceVersion: info.serviceVersion } : {}), + flavor: registration.flavor, + ...(registration.buildId ? { buildId: registration.buildId } : {}), + launchMode: info?.launchMode ?? 'shared', + ...(registration.logPath ? { logPath: registration.logPath } : {}) + }) } export function runtimeDiscoveryDirectory( diff --git a/kun/src/manager/manager-client.ts b/kun/src/manager/manager-client.ts index be2efb6cf..9d1873468 100644 --- a/kun/src/manager/manager-client.ts +++ b/kun/src/manager/manager-client.ts @@ -62,6 +62,13 @@ export class ManagerRevisionConflictError extends Error { } } +export class ManagerRuntimeSlotBusyError extends Error { + constructor(readonly owner: RuntimeRegistration) { + super(`Kun runtime slot ${owner.flavor} is already owned by ${owner.instanceId}`) + this.name = 'ManagerRuntimeSlotBusyError' + } +} + export class ManagerRevisionedDocumentClient { constructor( private readonly manager: ServiceManagerConnection, @@ -434,12 +441,21 @@ export async function registerRuntimeWithManager(input: { registration: RuntimeRegistration fetch?: typeof fetch }): Promise { - const response = await requestManagerJson(input.manager, `/v1/runtimes/${input.registration.flavor}/register`, { + const response = await requestManagerResponse(input.manager, `/v1/runtimes/${input.registration.flavor}/register`, { method: 'PUT', body: input.registration, fetch: input.fetch }) - const parsed = z.object({ registration: RuntimeRegistrationSchema }).parse(response) + if (response.status === 409) { + const conflict = z.object({ + code: z.literal('runtime_slot_busy'), + owner: RuntimeRegistrationSchema + }).safeParse(await response.json().catch(() => null)) + if (conflict.success) throw new ManagerRuntimeSlotBusyError(conflict.data.owner) + } + const parsed = z.object({ registration: RuntimeRegistrationSchema }).parse( + await requireManagerJson(response) + ) return parsed.registration } @@ -454,7 +470,10 @@ export async function heartbeatRuntimeWithManager(input: { body: { instanceId: input.instanceId }, fetch: input.fetch }) - return response.ok + if (response.ok) return true + if (response.status === 409) return false + await requireManagerJson(response) + return false } export async function unregisterRuntimeWithManager(input: { diff --git a/kun/src/manager/service-manager.test.ts b/kun/src/manager/service-manager.test.ts index 9ae915d1f..c447db549 100644 --- a/kun/src/manager/service-manager.test.ts +++ b/kun/src/manager/service-manager.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { dispatchRequest } from '../server/http-server.js' +import { + ManagerRuntimeSlotBusyError, + registerRuntimeWithManager, + type ServiceManagerConnection +} from './manager-client.js' import { buildServiceManagerRouter, + RuntimeSlotBusyError, ServiceManagerState, ThreadLeaseBusyError } from './service-manager.js' @@ -69,6 +75,93 @@ describe('service manager control plane', () => { expect(state.registration('development')?.port).toBe(18999) }) + it('preserves one owner per flavor until the registered slot expires', async () => { + const state = new ServiceManagerState() + const started = new Date('2026-08-01T00:00:00.000Z') + const owner = registration('production', 'runtime-owner') + state.register(owner, started) + + expect(() => state.register( + registration('production', 'runtime-contender'), + new Date('2026-08-01T00:00:01.000Z') + )).toThrow(RuntimeSlotBusyError) + expect(state.registration('production')).toMatchObject({ instanceId: owner.instanceId }) + + expect(state.register({ ...owner, port: 18901 }, new Date('2026-08-01T00:00:02.000Z'))) + .toMatchObject({ instanceId: owner.instanceId, port: 18901 }) + + state.expireStale(new Date('2026-08-01T00:00:23.000Z')) + expect(state.register( + registration('production', 'runtime-contender'), + new Date('2026-08-01T00:00:23.000Z') + )).toMatchObject({ instanceId: 'runtime-contender' }) + }) + + it('returns the current registration when a runtime slot is busy', async () => { + const state = new ServiceManagerState() + const owner = registration('production', 'runtime-owner') + state.register(owner) + const router = buildServiceManagerRouter({ + managerToken: 'manager-secret', + instanceId: 'manager-a', + startedAt: '2026-08-01T00:00:00.000Z', + state + }) + + const response = await dispatchRequest(router, request('/v1/runtimes/production/register', { + method: 'PUT', + body: JSON.stringify(registration('production', 'runtime-contender')) + })) + + expect(response.status).toBe(409) + await expect(response.json()).resolves.toMatchObject({ + code: 'runtime_slot_busy', + owner: { flavor: 'production', instanceId: owner.instanceId } + }) + expect(state.registration('production')).toMatchObject({ instanceId: owner.instanceId }) + }) + + it('parses runtime slot conflicts into a typed manager client error', async () => { + const state = new ServiceManagerState() + const owner = registration('production', 'runtime-owner') + state.register(owner) + const router = buildServiceManagerRouter({ + managerToken: 'manager-secret', + instanceId: 'manager-a', + startedAt: '2026-08-01T00:00:00.000Z', + state + }) + const manager: ServiceManagerConnection = { + discovery: { + version: 1, + protocolVersion: 1, + instanceId: 'manager-a', + pid: process.pid, + startedAt: '2026-08-01T00:00:00.000Z', + host: '127.0.0.1', + port: 18700, + baseUrl: 'http://127.0.0.1:18700', + managerToken: 'manager-secret', + serviceVersion: '0.1.0', + dataDir: '/tmp/kun-data', + settingsPath: '/tmp/kun-settings.json' + } + } + const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => + dispatchRequest(router, new Request(url, init))) as typeof fetch + + const conflict = await registerRuntimeWithManager({ + manager, + registration: registration('production', 'runtime-contender'), + fetch: fetchImpl + }).catch((error: unknown) => error) + expect(conflict).toBeInstanceOf(ManagerRuntimeSlotBusyError) + expect(conflict).toMatchObject({ + name: 'ManagerRuntimeSlotBusyError', + owner: { instanceId: owner.instanceId } + }) + }) + it('rejects unauthenticated registration and stale heartbeats', async () => { const state = new ServiceManagerState() const router = buildServiceManagerRouter({ diff --git a/kun/src/manager/service-manager.ts b/kun/src/manager/service-manager.ts index 29de97191..8df2b0b94 100644 --- a/kun/src/manager/service-manager.ts +++ b/kun/src/manager/service-manager.ts @@ -113,6 +113,13 @@ export class ThreadLeaseBusyError extends Error { } } +export class RuntimeSlotBusyError extends Error { + constructor(readonly owner: RuntimeRegistration) { + super(`runtime_slot_busy: ${owner.flavor} is owned by ${owner.instanceId}`) + this.name = 'RuntimeSlotBusyError' + } +} + export class RuntimeRegistrationRequiredError extends Error {} export class ServiceManagerState { @@ -147,12 +154,7 @@ export class ServiceManagerState { const parsed = RuntimeRegistrationSchema.parse(registration) const existing = this.slots.get(parsed.flavor) if (existing && existing.registration.instanceId !== parsed.instanceId) { - const activeLease = [...this.leases.values()].find((lease) => - lease.ownerFlavor === parsed.flavor && - lease.ownerInstanceId === existing.registration.instanceId && - Date.parse(lease.expiresAt) > now.getTime() - ) - if (activeLease) throw new ThreadLeaseBusyError(activeLease) + throw new RuntimeSlotBusyError(existing.registration) } this.slots.set(parsed.flavor, { registration: parsed, @@ -619,11 +621,11 @@ export function buildServiceManagerRouter(input: { try { return jsonResponse({ registration: input.state.register(registration.data) }) } catch (error) { - if (error instanceof ThreadLeaseBusyError) { + if (error instanceof RuntimeSlotBusyError) { return jsonResponse({ code: 'runtime_slot_busy', - message: 'runtime slot owns an active thread lease', - owner: error.lease + message: error.message, + owner: error.owner }, 409) } throw error diff --git a/kun/src/server/node-http-server.test.ts b/kun/src/server/node-http-server.test.ts index c49637331..8c77fb813 100644 --- a/kun/src/server/node-http-server.test.ts +++ b/kun/src/server/node-http-server.test.ts @@ -1,9 +1,54 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { startNodeHttpServer } from './node-http-server.js' import { jsonResponse } from './response.js' import { Router } from './router.js' describe('startNodeHttpServer', () => { + it('logs sanitized context before returning an unexpected internal error', async () => { + const router = new Router() + router.add('POST', '/broken', () => { + const error = new Error('rename metadata.jsonl.compact.tmp failed') as NodeJS.ErrnoException + error.code = 'EPERM' + throw error + }) + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const server = await startNodeHttpServer({ + router, + host: '127.0.0.1', + port: 0 + }) + try { + const response = await fetch( + `http://127.0.0.1:${server.port}/broken?runtimeToken=do-not-log`, + { + method: 'POST', + headers: { authorization: 'Bearer do-not-log' } + } + ) + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + code: 'internal_error', + message: 'Internal server error.' + }) + expect(log).toHaveBeenCalledWith( + '[kun-http] unexpected request failure', + { + method: 'POST', + pathname: '/broken', + error: { + name: 'Error', + message: 'rename metadata.jsonl.compact.tmp failed', + code: 'EPERM' + } + } + ) + expect(JSON.stringify(log.mock.calls)).not.toContain('do-not-log') + } finally { + log.mockRestore() + await server.close() + } + }) + it('does not crash when a response stream fails after headers were sent', async () => { const router = new Router() router.add('GET', '/broken-stream', () => { diff --git a/kun/src/server/node-http-server.ts b/kun/src/server/node-http-server.ts index 80aa1f1f9..3562252bc 100644 --- a/kun/src/server/node-http-server.ts +++ b/kun/src/server/node-http-server.ts @@ -76,7 +76,7 @@ async function handleNodeRequest( } finally { adapted.dispose() } - } catch { + } catch (error) { // A streaming body can fail after status/headers were already written, or // the peer can disconnect while the response is draining. Sending a // second 500 response would throw ERR_HTTP_HEADERS_SENT and crash a @@ -86,6 +86,11 @@ async function handleNodeRequest( if (!outgoing.writableEnded && !outgoing.destroyed) outgoing.destroy() return } + console.error('[kun-http] unexpected request failure', { + method: incoming.method ?? 'GET', + pathname: safeRequestPathname(incoming.url), + error: summarizeRequestError(error) + }) const body = JSON.stringify({ code: 'internal_error', message: 'Internal server error.' @@ -95,6 +100,26 @@ async function handleNodeRequest( } } +function safeRequestPathname(value: string | undefined): string { + try { + return new URL(value ?? '/', 'http://127.0.0.1').pathname.slice(0, 2_048) + } catch { + return '/' + } +} + +function summarizeRequestError(error: unknown): { name: string; message: string; code?: string } { + if (!(error instanceof Error)) { + return { name: 'Error', message: String(error).slice(0, 2_048) } + } + const code = String((error as NodeJS.ErrnoException).code ?? '').trim() + return { + name: error.name, + message: error.message.slice(0, 2_048), + ...(code ? { code: code.slice(0, 128) } : {}) + } +} + function sendInjectedResponse(outgoing: ServerResponse, status: number, body: unknown): void { outgoing.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) outgoing.end(JSON.stringify(body)) diff --git a/kun/src/server/runtime-factory.ts b/kun/src/server/runtime-factory.ts index 3d45abdc0..11913b9c5 100644 --- a/kun/src/server/runtime-factory.ts +++ b/kun/src/server/runtime-factory.ts @@ -159,6 +159,10 @@ import { TurnService } from '../services/turn-service.js' import { ReviewService } from '../services/review-service.js' import { UsageService } from '../services/usage-service.js' import { ProviderQuotaService } from '../services/provider-quota-service.js' +import { + resolveDefaultCodexQuotaCredential, + resolveDefaultGrokQuotaCredential +} from '../services/provider-subscription-quota.js' import { RoutePoolTestService } from '../services/route-pool-test-service.js' import type { UsageEvent } from '../contracts/events.js' import type { @@ -818,13 +822,54 @@ export async function createKunServeRuntime( kind: profile.kind, ...(profile.baseUrl ? { baseUrl: profile.baseUrl } : {}), apiKey, - ...(headers ? { headers } : {}) + ...(headers ? { headers } : {}), + ...(config?.credentialSourceId + ? { credentialSourceId: config.credentialSourceId } + : {}) } })) return { profiles, proxyUrl: snapshot.proxy.enabled ? snapshot.proxy.url : '' } + }, + subscriptionRuntime: { + resolveCodexCredential: async (provider, rejectedAccessToken) => { + if (!provider.credentialSourceId) { + return resolveDefaultCodexQuotaCredential(provider, rejectedAccessToken) + } + try { + const resolved = await resolveLegacyRequestCredentials( + provider.credentialSourceId, + rejectedAccessToken + ) + const accessToken = resolved.apiKey.trim() + if (!accessToken) return undefined + const accountId = new Headers(resolved.headers).get('chatgpt-account-id')?.trim() + return { + accessToken, + ...(accountId ? { accountId } : {}) + } + } catch { + return undefined + } + }, + resolveGrokCredential: async (provider, rejectedAccessToken) => { + if (!provider.credentialSourceId) { + return resolveDefaultGrokQuotaCredential(provider, rejectedAccessToken) + } + try { + const resolved = await resolveLegacyRequestCredentials( + provider.credentialSourceId, + rejectedAccessToken + ) + const accessToken = resolved.apiKey.trim() + if (!accessToken || accessToken === rejectedAccessToken) return undefined + return { accessToken } + } catch { + return undefined + } + } } }) const claudeConnections = new ClaudeConnectionService({ dataDir: activeOptions.dataDir }) diff --git a/kun/src/services/provider-quota-service.test.ts b/kun/src/services/provider-quota-service.test.ts index 2b5dca6a1..507fe61ea 100644 --- a/kun/src/services/provider-quota-service.test.ts +++ b/kun/src/services/provider-quota-service.test.ts @@ -24,6 +24,29 @@ const profile = ( ...overrides }) +function grokBillingFrame(usedPercent: number, resetEpoch: number): Uint8Array { + const float = Buffer.alloc(4) + float.writeFloatLE(usedPercent) + const varint: number[] = [] + let remaining = resetEpoch + do { + const next = remaining % 128 + remaining = Math.floor(remaining / 128) + varint.push(next | (remaining > 0 ? 0x80 : 0)) + } while (remaining > 0) + const payload = Buffer.concat([ + Buffer.from([0x0d]), + float, + Buffer.from([0x10, ...varint]) + ]) + const frame = Buffer.alloc(5 + payload.length) + frame.writeUInt32BE(payload.length, 1) + payload.copy(frame, 5) + const output = new Uint8Array(frame.length) + output.set(frame) + return output +} + describe('ProviderQuotaService', () => { it('classifies only exact supported provider hosts and subscription presets', () => { expect(classifyProviderQuotaProbe(profile())?.kind).toBe('deepseek') @@ -51,6 +74,14 @@ describe('ProviderQuotaService', () => { presetId: 'grok-subscription', baseUrl: 'https://cli-chat-proxy.grok.com/v1' }))?.kind).toBe('grok-subscription') + expect(classifyProviderQuotaProbe(profile({ + id: 'gemini-subscription-2', + name: 'Antigravity clone', + presetId: 'gemini-subscription-2', + kind: 'antigravity-cli', + baseUrl: undefined, + apiKey: '' + }))?.kind).toBe('antigravity-subscription') expect(classifyProviderQuotaProbe(profile({ id: 'kimi-code', name: 'Kimi Code', @@ -172,6 +203,160 @@ describe('ProviderQuotaService', () => { }) expect(fetcher).not.toHaveBeenCalled() }) + + it('refreshes and retries a rejected Codex quota credential', async () => { + const resolveCodexCredential = vi.fn(async ( + _provider: ProviderQuotaProbeProfile, + rejectedAccessToken?: string + ) => rejectedAccessToken + ? { accessToken: 'codex-retry-access', accountId: 'acct-retry' } + : { accessToken: 'codex-rejected-access', accountId: 'acct-retry' }) + const fetcher = vi.fn(async ( + _input: string | URL, + init: RequestInit | undefined + ) => { + const authorization = new Headers(init?.headers).get('authorization') + if (authorization === 'Bearer codex-rejected-access') { + return new Response('expired', { status: 403 }) + } + expect(authorization).toBe('Bearer codex-retry-access') + expect(new Headers(init?.headers).get('user-agent')).toMatch(/^codex_cli_rs\//) + return Response.json({ + plan_type: 'pro', + rate_limit: { + primary_window: { + used_percent: 9, + reset_at: 1_900_000_000, + limit_window_seconds: 18_000 + } + } + }) + }) + const service = new ProviderQuotaService({ + loadSource: async () => ({ + profiles: [profile({ + id: 'codex', + name: 'Codex', + presetId: 'codex', + baseUrl: 'https://chatgpt.com/backend-api/codex/responses', + apiKey: '' + })], + proxyUrl: '' + }), + fetcher, + subscriptionRuntime: { resolveCodexCredential } + }) + + await expect(service.list()).resolves.toMatchObject({ + entries: [{ + providerId: 'codex', + status: 'available', + metrics: [expect.objectContaining({ id: 'primary', usedPercent: 9 })] + }] + }) + expect(resolveCodexCredential).toHaveBeenNthCalledWith( + 2, + expect.anything(), + 'codex-rejected-access' + ) + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it('refreshes and retries a rejected Grok quota credential', async () => { + const resolveGrokCredential = vi.fn(async ( + _provider: ProviderQuotaProbeProfile, + rejectedAccessToken?: string + ) => rejectedAccessToken + ? { accessToken: 'grok-retry-access' } + : { accessToken: 'grok-rejected-access' }) + const fetcher = vi.fn(async ( + _input: string | URL, + init: RequestInit | undefined + ) => { + const authorization = new Headers(init?.headers).get('authorization') + if (authorization === 'Bearer grok-rejected-access') { + return new Response('expired', { status: 403 }) + } + expect(authorization).toBe('Bearer grok-retry-access') + return new Response(grokBillingFrame(14, 1_900_000_000), { + headers: { 'Content-Type': 'application/grpc-web+proto' } + }) + }) + const service = new ProviderQuotaService({ + loadSource: async () => ({ + profiles: [profile({ + id: 'grok-subscription', + name: 'Grok', + presetId: 'grok-subscription', + baseUrl: 'https://cli-chat-proxy.grok.com/v1', + apiKey: '' + })], + proxyUrl: '' + }), + fetcher, + subscriptionRuntime: { resolveGrokCredential } + }) + + await expect(service.list()).resolves.toMatchObject({ + entries: [{ + providerId: 'grok-subscription', + status: 'available', + metrics: [expect.objectContaining({ id: 'credits', usedPercent: 14 })] + }] + }) + expect(resolveGrokCredential).toHaveBeenNthCalledWith( + 2, + expect.anything(), + 'grok-rejected-access' + ) + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it('reports the Gemini CLI migration reason before requesting quota', async () => { + const fetcher = vi.fn(async ( + input: string | URL, + init: RequestInit | undefined + ) => { + expect(String(input)).toContain(':loadCodeAssist') + const headers = new Headers(init?.headers) + expect(headers.get('user-agent')).toBe('google-gemini-cli') + expect(JSON.parse(String(init?.body))).toMatchObject({ + metadata: { ideType: 'IDE_UNSPECIFIED', pluginType: 'GEMINI' } + }) + return Response.json({ + allowedTiers: [{ id: 'standard-tier' }], + ineligibleTiers: [{ + reasonMessage: 'This client is no longer supported. Migrate to Antigravity.' + }] + }) + }) + const service = new ProviderQuotaService({ + loadSource: async () => ({ + profiles: [profile({ + id: 'gemini-cli-subscription', + name: 'Gemini CLI', + presetId: 'gemini-cli-subscription', + kind: 'gemini-cli-api', + baseUrl: undefined, + apiKey: '' + })], + proxyUrl: '' + }), + fetcher, + subscriptionRuntime: { + resolveGeminiCliToken: async () => 'gemini-cli-access' + } + }) + + await expect(service.list()).resolves.toMatchObject({ + entries: [{ + providerId: 'gemini-cli-subscription', + status: 'error', + message: 'This client is no longer supported. Migrate to Antigravity.' + }] + }) + expect(fetcher).toHaveBeenCalledTimes(1) + }) }) describe('provider quota response parsers', () => { diff --git a/kun/src/services/provider-quota-service.ts b/kun/src/services/provider-quota-service.ts index 602d288ef..7a1be7f79 100644 --- a/kun/src/services/provider-quota-service.ts +++ b/kun/src/services/provider-quota-service.ts @@ -185,14 +185,14 @@ export function classifyProviderQuotaProbe( dashboardUrl: 'https://cursor.com/dashboard?tab=usage' } } - if (stableId === 'gemini-subscription' && provider.kind === 'antigravity-cli') { + if (provider.kind === 'antigravity-cli') { return { kind: 'antigravity-subscription', source: 'Google Antigravity quota API', dashboardUrl: 'https://antigravity.google' } } - if (stableId === 'gemini-cli-subscription' && provider.kind === 'gemini-cli-api') { + if (provider.kind === 'gemini-cli-api') { return { kind: 'gemini-cli-subscription', source: 'Google Gemini CLI quota API', diff --git a/kun/src/services/provider-subscription-quota.ts b/kun/src/services/provider-subscription-quota.ts index 8a571a3e1..712b8ca89 100644 --- a/kun/src/services/provider-subscription-quota.ts +++ b/kun/src/services/provider-subscription-quota.ts @@ -5,7 +5,22 @@ import { join } from 'node:path' import { promisify } from 'node:util' import type { ProviderQuotaMetric } from '../contracts/provider-quota.js' import { GeminiCliOAuthSource } from '../adapters/model/gemini-cli-oauth.js' -import { parseStoredCodexOAuthCredentials } from './codex-oauth-credential-refresher.js' +import { + codexCliUserAgent, + geminiCliRequestHeaders +} from '../adapters/model/provider-cli-identity.js' +import { + isStoredCodexCredentialExpired, + parseStoredCodexOAuthCredentials, + refreshStoredCodexOAuthCredentials, + type StoredCodexOAuthCredentials +} from './codex-oauth-credential-refresher.js' +import { + isStoredGrokCredentialExpired, + parseStoredGrokOAuthCredentials, + refreshStoredGrokOAuthCredentials, + type StoredGrokOAuthCredentials +} from './grok-oauth-credential-refresher.js' import { readOpenCodeGoLocalQuota, type OpenCodeGoLocalQuotaResult @@ -14,6 +29,8 @@ import { const execFileAsync = promisify(execFile) const QUOTA_TIMEOUT_MS = 12_000 const MAX_RESPONSE_BYTES = 256 * 1024 +const codexQuotaCredentialCache = new Map() +const grokQuotaCredentialCache = new Map() export type ProviderQuotaProbeProfile = { id: string @@ -23,6 +40,7 @@ export type ProviderQuotaProbeProfile = { baseUrl?: string apiKey: string headers?: Record + credentialSourceId?: string } export type SubscriptionQuotaProbeKind = @@ -66,8 +84,14 @@ type GoogleCredential = { export type SubscriptionQuotaRuntime = { resolveClaudeToken(provider: ProviderQuotaProbeProfile): Promise - resolveCodexCredential(provider: ProviderQuotaProbeProfile): Promise - resolveGrokCredential(provider: ProviderQuotaProbeProfile): Promise + resolveCodexCredential( + provider: ProviderQuotaProbeProfile, + rejectedAccessToken?: string + ): Promise + resolveGrokCredential( + provider: ProviderQuotaProbeProfile, + rejectedAccessToken?: string + ): Promise resolveCursorSession(): Promise resolveAntigravityCredential(context: ProbeContext): Promise resolveGeminiCliToken(context: ProbeContext): Promise @@ -81,6 +105,13 @@ export class ProviderQuotaMissingCredentialError extends Error { } } +class ProviderQuotaAuthorizationError extends Error { + constructor(readonly status: number) { + super('The provider did not authorize quota access for the existing login.') + this.name = 'ProviderQuotaAuthorizationError' + } +} + export async function runSubscriptionQuotaProbe( kind: SubscriptionQuotaProbeKind, provider: ProviderQuotaProbeProfile, @@ -111,35 +142,51 @@ export async function runSubscriptionQuotaProbe( } } if (kind === 'codex-subscription') { - const credential = await runtime.resolveCodexCredential(provider) + let credential = await runtime.resolveCodexCredential(provider) if (!credential) { throw new ProviderQuotaMissingCredentialError( 'Connect the ChatGPT subscription or sign in with Codex CLI first.' ) } - return parseCodexSubscriptionQuota(await requestJson( - 'https://chatgpt.com/backend-api/wham/usage', - { - headers: { - Accept: 'application/json', - Authorization: `Bearer ${credential.accessToken}`, - 'User-Agent': 'Kun', - ...(credential.accountId ? { 'ChatGPT-Account-Id': credential.accountId } : {}) - } - }, - context - )) + try { + return parseCodexSubscriptionQuota(await requestCodexSubscriptionQuota(credential, context)) + } catch (error) { + if (!(error instanceof ProviderQuotaAuthorizationError)) throw error + const refreshed = await runtime.resolveCodexCredential(provider, credential.accessToken) + if (!refreshed || refreshed.accessToken === credential.accessToken) { + throw new ProviderQuotaMissingCredentialError( + 'The Codex login expired. Sign in to the ChatGPT subscription or Codex CLI again.' + ) + } + credential = refreshed + return parseCodexSubscriptionQuota(await requestCodexSubscriptionQuota(credential, context)) + } } if (kind === 'grok-subscription') { - const credential = await runtime.resolveGrokCredential(provider) + let credential = await runtime.resolveGrokCredential(provider) if (!credential) { throw new ProviderQuotaMissingCredentialError( 'Connect Grok or run `grok login` before refreshing quota.' ) } - return { - metrics: await probeGrokSubscriptionQuota(credential, context), - ...(credential.email ? { summary: credential.email } : {}) + try { + return { + metrics: await probeGrokSubscriptionQuota(credential, context), + ...(credential.email ? { summary: credential.email } : {}) + } + } catch (error) { + if (!(error instanceof ProviderQuotaAuthorizationError)) throw error + const refreshed = await runtime.resolveGrokCredential(provider, credential.accessToken) + if (!refreshed || refreshed.accessToken === credential.accessToken) { + throw new ProviderQuotaMissingCredentialError( + 'The Grok login expired. Connect Grok or run `grok login` again.' + ) + } + credential = refreshed + return { + metrics: await probeGrokSubscriptionQuota(credential, context), + ...(credential.email ? { summary: credential.email } : {}) + } } } if (kind === 'cursor-subscription') { @@ -162,7 +209,7 @@ export async function runSubscriptionQuotaProbe( 'Sign in to the official Antigravity app before refreshing quota.' ) } - return probeGoogleCodeAssistQuota(credential, context) + return probeGoogleCodeAssistQuota(credential, context, 'antigravity') } if (kind === 'opencode-go-local') { const quota = await runtime.resolveOpenCodeGoQuota() @@ -179,7 +226,7 @@ export async function runSubscriptionQuotaProbe( 'Run Gemini CLI and sign in with Google before refreshing quota.' ) } - return probeGoogleCodeAssistQuota({ accessToken }, context) + return probeGoogleCodeAssistQuota({ accessToken }, context, 'gemini-cli') } export function parseClaudeSubscriptionQuota(payload: unknown): ProviderQuotaMetric[] { @@ -390,8 +437,8 @@ export function parseGoogleCodeAssistQuota(payload: unknown): ProviderQuotaMetri const defaultRuntime: SubscriptionQuotaRuntime = { resolveClaudeToken, - resolveCodexCredential, - resolveGrokCredential, + resolveCodexCredential: resolveDefaultCodexQuotaCredential, + resolveGrokCredential: resolveDefaultGrokQuotaCredential, resolveCursorSession, resolveAntigravityCredential, resolveOpenCodeGoQuota: readOpenCodeGoLocalQuota, @@ -441,13 +488,17 @@ async function probeGrokSubscriptionQuota( async function probeGoogleCodeAssistQuota( credential: GoogleCredential, - context: ProbeContext + context: ProbeContext, + client: 'antigravity' | 'gemini-cli' ): Promise<{ metrics: ProviderQuotaMetric[]; summary?: string }> { + const clientHeaders = client === 'gemini-cli' + ? geminiCliRequestHeaders() + : { 'User-Agent': 'antigravity' } const headers = { Accept: 'application/json', Authorization: `Bearer ${credential.accessToken}`, 'Content-Type': 'application/json', - 'User-Agent': 'antigravity' + ...clientHeaders } const setup = requireRecord(await requestJson( 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist', @@ -456,7 +507,7 @@ async function probeGoogleCodeAssistQuota( headers, body: JSON.stringify({ metadata: { - ideType: 'ANTIGRAVITY', + ideType: client === 'antigravity' ? 'ANTIGRAVITY' : 'IDE_UNSPECIFIED', platform: 'PLATFORM_UNSPECIFIED', pluginType: 'GEMINI' } @@ -469,6 +520,18 @@ async function probeGoogleCodeAssistQuota( ? projectValue.trim() : stringValue(optionalRecord(projectValue)?.id) || stringValue(optionalRecord(projectValue)?.projectId) + if (!project) { + const reason = Array.isArray(setup.ineligibleTiers) + ? setup.ineligibleTiers + .map((value) => stringValue(optionalRecord(value)?.reasonMessage)) + .filter(Boolean) + .join('; ') + : '' + throw new Error( + reason || + 'Google Code Assist account setup is incomplete. Finish provider onboarding and retry.' + ) + } const body = JSON.stringify(project ? { project } : {}) let quotaPayload: unknown try { @@ -519,11 +582,29 @@ async function resolveClaudeToken( } } -async function resolveCodexCredential( - provider: ProviderQuotaProbeProfile +export async function resolveDefaultCodexQuotaCredential( + provider: ProviderQuotaProbeProfile, + rejectedAccessToken?: string ): Promise { - const stored = parseStoredCodexOAuthCredentials(provider.apiKey) - if (stored) return { accessToken: stored.accessToken, accountId: stored.accountId } + let stored = parseStoredCodexOAuthCredentials(provider.apiKey) + if (stored) { + const cached = codexQuotaCredentialCache.get(stored.refreshToken) + if (cached) stored = cached + const rejectedCurrentToken = Boolean( + rejectedAccessToken && stored.accessToken === rejectedAccessToken + ) + if (rejectedCurrentToken || isStoredCodexCredentialExpired(stored)) { + const refreshed = await refreshCodexQuotaCredential(stored) + if (!refreshed) { + if (!rejectedCurrentToken && Date.now() < stored.expiresAt) { + return codexCredential(stored) + } + return undefined + } + return codexCredential(refreshed) + } + return codexCredential(stored) + } if (provider.apiKey.trim()) { return { accessToken: provider.apiKey.trim(), @@ -537,9 +618,55 @@ async function resolveCodexCredential( const accessToken = stringValue(tokens?.access_token) || stringValue(tokens?.accessToken) if (!accessToken) return undefined const accountId = stringValue(tokens?.account_id) || stringValue(tokens?.accountId) + const refreshToken = stringValue(tokens?.refresh_token) || stringValue(tokens?.refreshToken) + if (accountId && refreshToken) { + stored = { + kind: 'codex-oauth', + accessToken, + refreshToken, + expiresAt: (numberValue(jwtClaims(accessToken)?.exp) ?? 0) * 1_000, + accountId + } + const cached = codexQuotaCredentialCache.get(stored.refreshToken) + if (cached) stored = cached + const rejectedCurrentToken = Boolean( + rejectedAccessToken && stored.accessToken === rejectedAccessToken + ) + if (rejectedCurrentToken || isStoredCodexCredentialExpired(stored)) { + const refreshed = await refreshCodexQuotaCredential(stored) + if (!refreshed) { + if (!rejectedCurrentToken && Date.now() < stored.expiresAt) { + return codexCredential(stored) + } + return undefined + } + return codexCredential(refreshed) + } + return codexCredential(stored) + } return { accessToken, ...(accountId ? { accountId } : {}) } } +async function refreshCodexQuotaCredential( + credentials: StoredCodexOAuthCredentials +): Promise { + try { + const refreshed = await refreshStoredCodexOAuthCredentials(credentials) + codexQuotaCredentialCache.set(credentials.refreshToken, refreshed) + codexQuotaCredentialCache.set(refreshed.refreshToken, refreshed) + return refreshed + } catch { + return undefined + } +} + +function codexCredential(credentials: StoredCodexOAuthCredentials): CodexCredential { + return { + accessToken: credentials.accessToken, + accountId: credentials.accountId + } +} + async function resolveCursorSession(): Promise { const dbPath = appStateDbPath('Cursor') if (!dbPath) return undefined @@ -617,21 +744,17 @@ export function decodeAntigravityUnifiedOAuth(value: string | undefined): { return undefined } -async function resolveGrokCredential( - provider: ProviderQuotaProbeProfile +export async function resolveDefaultGrokQuotaCredential( + provider: ProviderQuotaProbeProfile, + rejectedAccessToken?: string ): Promise { - const configured = optionalRecord(safeJson(provider.apiKey.trim())) - const configuredAccessToken = stringValue(configured?.accessToken) - const configuredExpiry = numberValue(configured?.expiresAt) - if ( - configuredAccessToken && - (configuredExpiry === undefined || configuredExpiry > Date.now()) - ) { - const email = stringValue(configured?.email) - return { - accessToken: configuredAccessToken, - ...(email ? { email } : {}) - } + const configured = parseStoredGrokOAuthCredentials(provider.apiKey.trim()) + if (configured) { + return refreshableGrokQuotaCredential(configured, rejectedAccessToken) + } + const configuredToken = provider.apiKey.trim() + if (configuredToken && !configuredToken.startsWith('{') && configuredToken !== rejectedAccessToken) { + return { accessToken: configuredToken } } const home = process.env.GROK_HOME?.trim() @@ -654,15 +777,62 @@ async function resolveGrokCredential( for (const [, rawEntry] of candidates) { const entry = optionalRecord(rawEntry) const accessToken = stringValue(entry?.key) - if (!accessToken) continue + if (!accessToken || accessToken === rejectedAccessToken) continue const expiresAt = isoDateValue(entry?.expires_at) - if (expiresAt && new Date(expiresAt).getTime() <= Date.now()) continue const email = stringValue(entry?.email) + const refreshToken = stringValue(entry?.refresh_token) + if (refreshToken) { + const credential = await refreshableGrokQuotaCredential({ + kind: 'grok-oauth', + accessToken, + refreshToken, + expiresAt: expiresAt ? new Date(expiresAt).getTime() : 0, + ...(email ? { email } : {}), + ...(stringValue(entry?.user_id) ? { userId: stringValue(entry?.user_id) } : {}), + ...(stringValue(entry?.oidc_issuer) ? { issuer: stringValue(entry?.oidc_issuer) } : {}), + ...(stringValue(entry?.oidc_client_id) ? { clientId: stringValue(entry?.oidc_client_id) } : {}) + }, rejectedAccessToken) + if (credential) return credential + continue + } + if (expiresAt && new Date(expiresAt).getTime() <= Date.now()) continue return { accessToken, ...(email ? { email } : {}) } } return undefined } +async function refreshableGrokQuotaCredential( + source: StoredGrokOAuthCredentials, + rejectedAccessToken?: string +): Promise { + const cached = grokQuotaCredentialCache.get(source.refreshToken) + const credential = cached ?? source + const rejectedCurrentToken = Boolean( + rejectedAccessToken && credential.accessToken === rejectedAccessToken + ) + if (!rejectedCurrentToken && !isStoredGrokCredentialExpired(credential)) { + return grokCredential(credential) + } + try { + const refreshed = await refreshStoredGrokOAuthCredentials(credential) + grokQuotaCredentialCache.set(source.refreshToken, refreshed) + grokQuotaCredentialCache.set(refreshed.refreshToken, refreshed) + return grokCredential(refreshed) + } catch { + if (!rejectedCurrentToken && credential.expiresAt > Date.now()) { + return grokCredential(credential) + } + return undefined + } +} + +function grokCredential(credentials: StoredGrokOAuthCredentials): GrokCredential { + return { + accessToken: credentials.accessToken, + ...(credentials.email ? { email: credentials.email } : {}) + } +} + async function discoverAntigravityOAuthClient(): Promise<{ clientId: string clientSecret: string @@ -737,7 +907,7 @@ async function requestJson( } if (!response.ok) { if (response.status === 401 || response.status === 403) { - throw new Error('The provider did not authorize quota access for the existing login.') + throw new ProviderQuotaAuthorizationError(response.status) } throw new Error(`The provider quota endpoint returned HTTP ${response.status}.`) } @@ -749,6 +919,24 @@ async function requestJson( } } +function requestCodexSubscriptionQuota( + credential: CodexCredential, + context: ProbeContext +): Promise { + return requestJson( + 'https://chatgpt.com/backend-api/wham/usage', + { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${credential.accessToken}`, + 'User-Agent': codexCliUserAgent(), + ...(credential.accountId ? { 'ChatGPT-Account-Id': credential.accountId } : {}) + } + }, + context + ) +} + async function requestResponse( url: string, input: { @@ -774,7 +962,7 @@ async function requestResponse( } if (!response.ok) { if (response.status === 401 || response.status === 403) { - throw new Error('The provider did not authorize quota access for the existing login.') + throw new ProviderQuotaAuthorizationError(response.status) } throw new Error(`The provider quota endpoint returned HTTP ${response.status}.`) } @@ -1186,15 +1374,6 @@ async function readJsonFile(path: string): Promise { } } -function safeJson(value: string): unknown { - if (!value) return undefined - try { - return JSON.parse(value) as unknown - } catch { - return undefined - } -} - function resolveHomePath(value: string): string { if (value === '~') return homedir() return value.startsWith('~/') ? join(homedir(), value.slice(2)) : value diff --git a/scripts/smoke-windows-installer-migration.ps1 b/scripts/smoke-windows-installer-migration.ps1 index 1f9461bbd..1fded1d82 100644 --- a/scripts/smoke-windows-installer-migration.ps1 +++ b/scripts/smoke-windows-installer-migration.ps1 @@ -16,6 +16,8 @@ if (-not $AllowLocal -and $env:CI -ne 'true') { $root = Join-Path ([IO.Path]::GetTempPath()) ('kun-installer-migration-smoke-' + [guid]::NewGuid().ToString('N')) $diagnosticPath = Join-Path $root 'installer-helper-diagnostics.log' $previousDiagnosticPath = [Environment]::GetEnvironmentVariable('KUN_INSTALLER_DIAGNOSTIC_PATH', 'Process') +$previousUpdateSource = [Environment]::GetEnvironmentVariable('KUN_INSTALLER_UPDATE_SOURCE', 'Process') +$previousAttackMarker = [Environment]::GetEnvironmentVariable('KUN_INSTALLER_ATTACK_MARKER', 'Process') $markerName = '.kun-installer-migration-smoke-' + [guid]::NewGuid().ToString('N') $installRegistryPath = $null $uninstallRegistryPath = $null @@ -23,6 +25,7 @@ $installRegistryPaths = @() $uninstallRegistryPaths = @() $sentinels = @() $currentScenario = 'smoke setup' +$substDrive = '' function Assert-True([bool]$Condition, [string]$Message) { if (-not $Condition) { @@ -89,13 +92,22 @@ function Set-RegisteredLocation([string]$Location) { Set-ItemProperty -LiteralPath $script:uninstallRegistryPath -Name DisplayIcon -Value ((Join-Path $Location 'Kun.exe') + ',0') } -function Move-RegisteredInstall([string]$From, [string]$To) { +function Move-RegisteredInstall( + [string]$From, + [string]$To, + [string]$RegisteredLocation = '' +) { [IO.Directory]::CreateDirectory((Split-Path -Parent $To)) | Out-Null Move-Item -LiteralPath $From -Destination $To - Set-RegisteredLocation $To + $registeredTarget = if ([string]::IsNullOrWhiteSpace($RegisteredLocation)) { + $To + } else { + $RegisteredLocation + } + Set-RegisteredLocation $registeredTarget $fromBin = Join-Path $From 'bin' - $toBin = Join-Path $To 'bin' + $toBin = Join-Path $registeredTarget 'bin' $pathValue = [Environment]::GetEnvironmentVariable('Path', 'User') $parts = @($pathValue -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and @@ -104,6 +116,41 @@ function Move-RegisteredInstall([string]$From, [string]$To) { [Environment]::SetEnvironmentVariable('Path', (($parts + $toBin) -join ';'), 'User') } +function New-TemporarySubstDrive([string]$BackingDirectory) { + [IO.Directory]::CreateDirectory($BackingDirectory) | Out-Null + foreach ($code in (90..80)) { + $drive = ([char]$code).ToString() + ':' + if (Test-Path -LiteralPath ($drive + '\')) { + continue + } + & "$env:SystemRoot\System32\subst.exe" $drive $BackingDirectory | Out-Null + if ($LASTEXITCODE -eq 0 -and (Test-Path -LiteralPath ($drive + '\'))) { + return $drive + } + } + throw 'No free drive letter was available for the external-install smoke scenario.' +} + +function New-SmokeMarkerExecutable([string]$PathValue) { + $source = @' +using System; +using System.IO; + +public static class KunInstallerSmokeAttacker +{ + public static void Main() + { + var marker = Environment.GetEnvironmentVariable("KUN_INSTALLER_ATTACK_MARKER"); + if (!String.IsNullOrWhiteSpace(marker)) + { + File.WriteAllText(marker, "executed"); + } + } +} +'@ + Add-Type -TypeDefinition $source -OutputAssembly $PathValue -OutputType ConsoleApplication +} + function Assert-RegisteredLocation([string]$ExpectedLocation) { $actual = Get-ItemPropertyValue -LiteralPath $script:installRegistryPath -Name InstallLocation Assert-True (Test-PathEqual $actual $ExpectedLocation) "Registered location is $actual, expected $ExpectedLocation." @@ -176,6 +223,8 @@ function Add-DataSentinel([string]$Directory) { try { [IO.Directory]::CreateDirectory($root) | Out-Null [Environment]::SetEnvironmentVariable('KUN_INSTALLER_DIAGNOSTIC_PATH', $diagnosticPath, 'Process') + [Environment]::SetEnvironmentVariable('KUN_INSTALLER_UPDATE_SOURCE', $null, 'Process') + [Environment]::SetEnvironmentVariable('KUN_INSTALLER_ATTACK_MARKER', $null, 'Process') if ([string]::IsNullOrWhiteSpace($InstallerPath)) { $candidate = Get-ChildItem -Path (Join-Path (Get-Location) 'dist') -Filter 'Kun-*-win-x64.exe' | Sort-Object LastWriteTimeUtc -Descending | @@ -224,22 +273,60 @@ try { Assert-PathReconciled $legacyTarget @($seed, $legacySource) Assert-KunShortcuts 'CurrentUser' + $falseNestedParent = Join-Path $root 'false-nested\DeepSeek GUI' + $falseNestedSource = Join-Path $falseNestedParent 'Kun' + $falseNestedTarget = Join-Path $root 'false-nested\Kun' + Move-RegisteredInstall $legacyTarget $falseNestedParent $falseNestedSource + [IO.Directory]::CreateDirectory($falseNestedSource) | Out-Null + Invoke-Installer 'false nested legacy registration recovery' @('/S', '/currentuser') + Assert-RegisteredLocation $falseNestedTarget + Assert-True (-not (Test-Path -LiteralPath (Join-Path $falseNestedParent 'Kun.exe'))) 'The recovered legacy parent still contains its application executable.' + Assert-True (-not (Test-Path -LiteralPath (Join-Path $falseNestedParent 'DeepSeek GUI.exe'))) 'The recovered legacy parent still contains its legacy executable.' + Assert-PathReconciled $falseNestedTarget @($legacyTarget, $falseNestedSource, $falseNestedParent) + $nestedSource = Join-Path $root 'nested\DeepSeek GUI\Kun' $nestedTarget = Join-Path $root 'nested\Kun' - Move-RegisteredInstall $legacyTarget $nestedSource + Move-RegisteredInstall $falseNestedTarget $nestedSource Set-Content -LiteralPath (Join-Path $nestedSource 'nested-note.txt') -Value 'keep nested note' Invoke-Installer 'nested legacy path migration' @('/S', '/currentuser') Assert-RegisteredLocation $nestedTarget Assert-True (Test-Path -LiteralPath (Join-Path $nestedSource 'nested-note.txt')) 'Nested unknown content was not restored.' - Assert-PathReconciled $nestedTarget @($legacyTarget, $nestedSource) + Assert-PathReconciled $nestedTarget @($falseNestedTarget, $nestedSource) + + $staleSource = Join-Path $root 'stale-empty\DeepSeek GUI\Kun' + $staleTarget = Join-Path $root 'stale-empty\Kun' + $orphanedInstall = Join-Path $root 'orphaned-valid-install' + Move-RegisteredInstall $nestedTarget $orphanedInstall $staleSource + [IO.Directory]::CreateDirectory($staleSource) | Out-Null + Invoke-Installer 'empty stale registration recovery' @('/S', '/currentuser') + Assert-RegisteredLocation $staleTarget + Assert-True (Test-Path -LiteralPath $staleSource -PathType Container) 'Stale empty source directory was modified or removed.' + Assert-True (@(Get-ChildItem -LiteralPath $staleSource -Force).Count -eq 0) 'Stale empty source directory received application files.' + Assert-PathReconciled $staleTarget @($nestedTarget, $staleSource) $custom = Join-Path $root 'custom\My AI Tools' - Move-RegisteredInstall $nestedTarget $custom + Move-RegisteredInstall $staleTarget $custom Set-Content -LiteralPath (Join-Path $custom 'custom-note.txt') -Value 'keep custom note' Invoke-Installer 'custom path reinstall' @('/S', '/currentuser') Assert-RegisteredLocation $custom Assert-True (Test-Path -LiteralPath (Join-Path $custom 'custom-note.txt')) 'Custom install content was not restored in place.' + Move-Item -LiteralPath (Join-Path $custom 'Kun.exe') -Destination (Join-Path $custom 'Kun.exe.partial-missing') + Invoke-Installer 'partially damaged packaged source recovery' @('/S', '/currentuser') + Assert-RegisteredLocation $custom + Assert-True (Test-Path -LiteralPath (Join-Path $custom 'Kun.exe.partial-missing')) 'Partial-source recovery deleted preserved content.' + + Move-Item -LiteralPath (Join-Path $custom 'Kun.exe') -Destination (Join-Path $custom 'Kun.exe.unverified') + Move-Item -LiteralPath (Join-Path $custom 'Uninstall Kun.exe') -Destination (Join-Path $custom 'Uninstall Kun.exe.unverified') + Move-Item -LiteralPath (Join-Path $custom 'resources\app.asar') -Destination (Join-Path $custom 'resources\app.asar.unverified') + Invoke-Installer 'unverified non-empty source rejection' @('/S', '/currentuser') 2 + Assert-True (Test-Path -LiteralPath (Join-Path $custom 'Kun.exe.unverified')) 'Unverified-source rejection changed the program directory.' + $unverifiedRegisteredLocation = Get-ItemPropertyValue -LiteralPath $script:installRegistryPath -Name InstallLocation + Assert-True (Test-PathEqual $unverifiedRegisteredLocation $custom) 'Unverified-source rejection retired the existing registration.' + Move-Item -LiteralPath (Join-Path $custom 'Kun.exe.unverified') -Destination (Join-Path $custom 'Kun.exe') + Move-Item -LiteralPath (Join-Path $custom 'Uninstall Kun.exe.unverified') -Destination (Join-Path $custom 'Uninstall Kun.exe') + Move-Item -LiteralPath (Join-Path $custom 'resources\app.asar.unverified') -Destination (Join-Path $custom 'resources\app.asar') + $programsRoot = Join-Path $env:LOCALAPPDATA 'Programs' Set-RegisteredLocation $programsRoot Invoke-Installer 'protected root rejection' @('/S', '/currentuser') 2 @@ -279,6 +366,14 @@ try { Invoke-Installer 'conflict retry migration' @('/S', '/currentuser') Assert-RegisteredLocation $conflictTarget + $externalBacking = Join-Path $root 'external-drive' + $script:substDrive = New-TemporarySubstDrive $externalBacking + $externalBackingSource = Join-Path $externalBacking 'Kun' + $externalSource = $script:substDrive + '\Kun' + Move-RegisteredInstall $conflictTarget $externalBackingSource $externalSource + Set-Content -LiteralPath (Join-Path $externalSource 'external-note.txt') -Value 'keep external note' + Assert-RegisteredLocation $externalSource + foreach ($sentinel in $sentinels) { Assert-True (Test-Path -LiteralPath $sentinel) "User-data sentinel was removed: $sentinel" } @@ -290,13 +385,45 @@ try { Invoke-Installer 'current-user to all-users migration' @('/S', '/allusers', "/D=$machineParent") Assert-True (-not (Test-Path -LiteralPath $previousUserInstallRegistryPath)) 'The current-user install registration remains after all-users migration.' Assert-True (-not (Test-Path -LiteralPath $previousUserUninstallRegistryPath)) 'The current-user uninstall registration remains after all-users migration.' - Assert-True (-not (Test-Path -LiteralPath (Join-Path $conflictTarget 'Kun.exe'))) 'The current-user application payload remains after all-users migration.' + Assert-True (-not (Test-Path -LiteralPath (Join-Path $externalSource 'Kun.exe'))) 'The external current-user application payload remains after all-users migration.' + Assert-True ((Get-Content -LiteralPath (Join-Path $externalSource 'external-note.txt') -Raw).Trim() -eq 'keep external note') 'External current-user content was not restored.' Assert-NoKunShortcuts 'CurrentUser' Find-KunRegistration $machineTarget 'HKLM' Assert-RegisteredLocation $machineTarget - Assert-PathReconciled $machineTarget @($conflictTarget) + Assert-PathReconciled $machineTarget @($conflictTarget, $externalSource) Assert-KunShortcuts 'AllUsers' + $machineInstallRegistryPath = $script:installRegistryPath + $machineUninstallRegistryPath = $script:uninstallRegistryPath + $otherUserParent = Join-Path $root 'other-current-user' + $otherUserTarget = Join-Path $otherUserParent 'Kun' + Invoke-Installer 'parallel current-user install' @('/S', '/currentuser', "/D=$otherUserParent") + Find-KunRegistration $otherUserTarget 'HKCU' + $otherUserInstallRegistryPath = $script:installRegistryPath + $otherUserUninstallRegistryPath = $script:uninstallRegistryPath + $otherUserUninstallString = Get-ItemPropertyValue -LiteralPath $otherUserUninstallRegistryPath -Name UninstallString + + $attackerPath = Join-Path $root 'tampered-uninstaller.exe' + $attackerMarker = Join-Path $root 'tampered-uninstaller-ran.txt' + New-SmokeMarkerExecutable $attackerPath + Set-ItemProperty -LiteralPath $machineUninstallRegistryPath -Name UninstallString -Value ('"' + $attackerPath + '"') + Set-ItemProperty -LiteralPath $machineUninstallRegistryPath -Name QuietUninstallString -Value ('"' + $attackerPath + '" /S') + [Environment]::SetEnvironmentVariable('KUN_INSTALLER_ATTACK_MARKER', $attackerMarker, 'Process') + [Environment]::SetEnvironmentVariable('KUN_INSTALLER_UPDATE_SOURCE', $machineTarget, 'Process') + Invoke-Installer 'in-app all-users automatic update scope' @('--updated', '/S', '--force-run') + [Environment]::SetEnvironmentVariable('KUN_INSTALLER_UPDATE_SOURCE', $previousUpdateSource, 'Process') + [Environment]::SetEnvironmentVariable('KUN_INSTALLER_ATTACK_MARKER', $previousAttackMarker, 'Process') + + Assert-True (-not (Test-Path -LiteralPath $attackerMarker)) 'The elevated update executed the tampered registry uninstaller.' + $machineLocationAfterUpdate = Get-ItemPropertyValue -LiteralPath $machineInstallRegistryPath -Name InstallLocation + Assert-True (Test-PathEqual $machineLocationAfterUpdate $machineTarget) 'The automatic update did not retain the all-users registration.' + $otherUserLocationAfterUpdate = Get-ItemPropertyValue -LiteralPath $otherUserInstallRegistryPath -Name InstallLocation + Assert-True (Test-PathEqual $otherUserLocationAfterUpdate $otherUserTarget) 'The automatic update changed the unrelated current-user registration.' + $otherUserUninstallAfterUpdate = Get-ItemPropertyValue -LiteralPath $otherUserUninstallRegistryPath -Name UninstallString + Assert-True ($otherUserUninstallAfterUpdate -eq $otherUserUninstallString) 'The automatic update did not restore the unrelated current-user uninstall registration.' + $automaticUpdateDiagnostics = Get-Content -LiteralPath $diagnosticPath -Raw + Assert-True ($automaticUpdateDiagnostics -match [regex]::Escape("source=$machineTarget")) 'The automatic update did not validate the running all-users source.' + $script:currentScenario = 'all-users uninstall' $machineUninstaller = Join-Path $machineTarget 'Uninstall Kun.exe' $machineUninstall = Start-Process -FilePath $machineUninstaller -ArgumentList @('/S', '/allusers') -Wait -PassThru @@ -308,6 +435,8 @@ try { Write-Host 'Windows installer migration smoke passed.' } finally { [Environment]::SetEnvironmentVariable('KUN_INSTALLER_DIAGNOSTIC_PATH', $previousDiagnosticPath, 'Process') + [Environment]::SetEnvironmentVariable('KUN_INSTALLER_UPDATE_SOURCE', $previousUpdateSource, 'Process') + [Environment]::SetEnvironmentVariable('KUN_INSTALLER_ATTACK_MARKER', $previousAttackMarker, 'Process') foreach ($sentinel in $sentinels) { Remove-Item -LiteralPath $sentinel -Force -ErrorAction SilentlyContinue } @@ -317,5 +446,8 @@ try { foreach ($registryPath in $uninstallRegistryPaths) { Remove-Item -LiteralPath $registryPath -Recurse -Force -ErrorAction SilentlyContinue } + if (-not [string]::IsNullOrWhiteSpace($substDrive)) { + & "$env:SystemRoot\System32\subst.exe" $substDrive /D | Out-Null + } Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } diff --git a/src/main/gui-updater.test.ts b/src/main/gui-updater.test.ts index 600311f50..641d33f96 100644 --- a/src/main/gui-updater.test.ts +++ b/src/main/gui-updater.test.ts @@ -203,6 +203,63 @@ describe('checkGuiUpdate feed URL', () => { }) describe('installGuiUpdate', () => { + it('passes the running Windows install directory to the spawned NSIS updater', async () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + const execPathDescriptor = Object.getOwnPropertyDescriptor(process, 'execPath') + Object.defineProperty(process, 'platform', { + ...platformDescriptor, + value: 'win32' + }) + Object.defineProperty(process, 'execPath', { + ...execPathDescriptor, + value: 'D:\\Apps\\Kun\\Kun.exe' + }) + + try { + const module = await import('./gui-updater') + updater.quitAndInstall.mockImplementation(() => { + expect(process.env.KUN_INSTALLER_UPDATE_SOURCE).toBe('D:\\Apps\\Kun') + }) + module.initializeGuiUpdater(() => null, () => 'stable') + updater.emit('update-downloaded', { + version: '0.2.0', + releaseDate: '2026-06-06T00:00:00.000Z' + }) + + await expect(module.installGuiUpdate()).resolves.toEqual({ ok: true }) + expect(updater.quitAndInstall).toHaveBeenCalledWith(true, true) + } finally { + if (platformDescriptor) Object.defineProperty(process, 'platform', platformDescriptor) + if (execPathDescriptor) Object.defineProperty(process, 'execPath', execPathDescriptor) + } + }) + + it('binds a Windows installer child to the running packaged application directory', async () => { + const module = await import('./gui-updater') + const env: NodeJS.ProcessEnv = { KUN_INSTALLER_UPDATE_SOURCE: 'C:\\Previous' } + + const restore = module.setWindowsInstallerUpdateSource( + env, + 'win32', + 'D:\\Apps\\Kun\\Kun.exe' + ) + + expect(env.KUN_INSTALLER_UPDATE_SOURCE).toBe('D:\\Apps\\Kun') + restore() + expect(env.KUN_INSTALLER_UPDATE_SOURCE).toBe('C:\\Previous') + + const emptyEnv: NodeJS.ProcessEnv = {} + const clear = module.setWindowsInstallerUpdateSource( + emptyEnv, + 'win32', + 'C:\\Users\\me\\AppData\\Local\\Programs\\Kun\\Kun.exe' + ) + expect(emptyEnv.KUN_INSTALLER_UPDATE_SOURCE) + .toBe('C:\\Users\\me\\AppData\\Local\\Programs\\Kun') + clear() + expect(emptyEnv.KUN_INSTALLER_UPDATE_SOURCE).toBeUndefined() + }) + it('waits for managed runtime cleanup before asking the updater to quit and install', async () => { const module = await import('./gui-updater') let finishCleanup = (): void => { diff --git a/src/main/gui-updater.ts b/src/main/gui-updater.ts index 2c2626b2b..c0ac317de 100644 --- a/src/main/gui-updater.ts +++ b/src/main/gui-updater.ts @@ -2,7 +2,7 @@ import { app, autoUpdater as nativeAutoUpdater, BrowserWindow, dialog, shell } f import type { MessageBoxOptions } from 'electron' import { existsSync, readFileSync } from 'node:fs' import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' +import { dirname, join, win32 as win32Path } from 'node:path' import electronUpdater from 'electron-updater' import type { ProgressInfo, UpdateDownloadedEvent, UpdateInfo } from 'electron-updater' import type { @@ -29,11 +29,30 @@ const { autoUpdater } = electronUpdater const DEVELOPMENT_APP_FLAVOR = process.env.KUN_APP_FLAVOR === 'development' const DEVELOPMENT_UPDATE_MESSAGE = 'kun-dv is a source/testing application and cannot use the production Kun update channel.' +const WINDOWS_INSTALLER_UPDATE_SOURCE_ENV = 'KUN_INSTALLER_UPDATE_SOURCE' function envWithLegacyFallback(kunName: string, legacyName: string): string { return process.env[kunName]?.trim() || process.env[legacyName]?.trim() || '' } +export function setWindowsInstallerUpdateSource( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + executablePath: string = process.execPath +): () => void { + if (platform !== 'win32') return () => undefined + const hadPrevious = Object.prototype.hasOwnProperty.call(env, WINDOWS_INSTALLER_UPDATE_SOURCE_ENV) + const previous = env[WINDOWS_INSTALLER_UPDATE_SOURCE_ENV] + env[WINDOWS_INSTALLER_UPDATE_SOURCE_ENV] = win32Path.dirname(executablePath) + return () => { + if (hadPrevious && previous !== undefined) { + env[WINDOWS_INSTALLER_UPDATE_SOURCE_ENV] = previous + } else { + delete env[WINDOWS_INSTALLER_UPDATE_SOURCE_ENV] + } + } +} + let initialized = false let getMainWindow: (() => BrowserWindow | null) | null = null let lastInfo: Extract | null = null @@ -799,6 +818,7 @@ export async function installGuiUpdate(): Promise { } } let updateInstallQuitMarked = false + let restoreInstallerUpdateSource = (): void => undefined try { if (!downloaded) { return { @@ -810,6 +830,7 @@ export async function installGuiUpdate(): Promise { } emitGuiUpdateState({ status: 'installing', info: lastInfo ?? undefined }) await Promise.all([pendingVersionStateWrite, runBeforeInstallUpdate()]) + restoreInstallerUpdateSource = setWindowsInstallerUpdateSource() markUpdateInstallQuitting(true) updateInstallQuitMarked = true // In-app updates must stay silent on Windows. The assisted NSIS UI can @@ -820,6 +841,7 @@ export async function installGuiUpdate(): Promise { autoUpdater.quitAndInstall(true, true) return { ok: true } } catch (e) { + restoreInstallerUpdateSource() if (updateInstallQuitMarked) markUpdateInstallQuitting(false) const message = e instanceof Error ? e.message : String(e) emitGuiUpdateState({ status: 'error', info: lastInfo ?? undefined, message, code: 'install_failed' }) diff --git a/src/main/index.ts b/src/main/index.ts index c29a066b9..b588103c1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -38,7 +38,7 @@ import { prepareTrayIcon } from './app-icon' import { buildTrayMenuTemplate, parseTrayThreads, type TrayThreadSummary } from './tray-session-menu' -import { listProviderQuotas } from './provider-quota' +import { requestRuntimeProviderQuotas } from './runtime-provider-quota' import { registerTrayQuotaIpc } from './tray-quota-ipc' import { resolveTrayQuotaAnchorBounds, @@ -2445,7 +2445,12 @@ app.whenReady().then(async () => { disposeTrayQuotaIpc = registerTrayQuotaIpc({ ipcMain, getWindow: () => trayQuotaWindow, - list: async () => listProviderQuotas(await store.load()), + list: async () => { + const settings = await store.load() + return requestRuntimeProviderQuotas((path, method) => + runtimeRequest(settings, path, { method }) + ) + }, context: async () => { const settings = await store.load() return { diff --git a/src/main/ipc/register-app-ipc-handlers.ts b/src/main/ipc/register-app-ipc-handlers.ts index cd32ac8da..44d184ef2 100644 --- a/src/main/ipc/register-app-ipc-handlers.ts +++ b/src/main/ipc/register-app-ipc-handlers.ts @@ -158,7 +158,7 @@ import { } from '../agent-sdk-installer' import type { JsonSettingsStore } from '../settings-store' import { probeModelProvider } from '../provider-connection' -import { listProviderQuotas } from '../provider-quota' +import { requestRuntimeProviderQuotas } from '../runtime-provider-quota' import { fetchModelsDevCatalog } from '../models-dev-catalog' import type { ClawRuntime } from '../claw-runtime' import type { ScheduleRuntime } from '../schedule-runtime' @@ -1104,7 +1104,7 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions): ipcMain.handle('provider:quota:list', async (event) => { assertTrustedWorkbenchSender(event, getMainWindow) - return listProviderQuotas(await store.load()) + return requestRuntimeProviderQuotas(runtimeRequest) }) ipcMain.handle('provider:models-dev-catalog', async (_, payload: unknown) => { diff --git a/src/main/ipc/register-extension-ipc-handlers.test.ts b/src/main/ipc/register-extension-ipc-handlers.test.ts index aa67eed17..359d4c080 100644 --- a/src/main/ipc/register-extension-ipc-handlers.test.ts +++ b/src/main/ipc/register-extension-ipc-handlers.test.ts @@ -2425,6 +2425,38 @@ describe('extension IPC security bridge', () => { stop() }) + it('backs off and deduplicates repeated secret reveal pump failures', async () => { + vi.useFakeTimers() + try { + const state = fixture() + const logError = vi.fn() + state.runtimeRequest.mockRejectedValue(new Error('fetch failed')) + const stop = startExtensionSecretRevealConsentPump({ + ...state.options, + logError + }, 250) + + await vi.advanceTimersByTimeAsync(0) + expect(state.runtimeRequest).toHaveBeenCalledTimes(1) + expect(logError).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(499) + expect(state.runtimeRequest).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(state.runtimeRequest).toHaveBeenCalledTimes(2) + expect(logError).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(999) + expect(state.runtimeRequest).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(state.runtimeRequest).toHaveBeenCalledTimes(3) + expect(logError).toHaveBeenCalledTimes(1) + stop() + } finally { + vi.useRealTimers() + } + }) + it('waits for a pending Main-owned dialog before opening a secret reveal prompt (#1053)', async () => { const state = fixture() state.runtimeRequest.mockImplementation(async (path: string, method?: string) => { @@ -2529,6 +2561,41 @@ describe('extension IPC security bridge', () => { )).rejects.toThrow(/trusted workbench frame/) }) + it('resets notification pump backoff after the runtime recovers', async () => { + vi.useFakeTimers() + try { + const state = fixture() + const logError = vi.fn() + state.runtimeRequest + .mockRejectedValueOnce(new Error('fetch failed')) + .mockResolvedValueOnce({ + ok: true, + status: 200, + body: JSON.stringify({ schemaVersion: 1, notifications: [] }) + }) + .mockRejectedValueOnce(new Error('fetch failed')) + const stop = startExtensionNotificationPump({ + ...state.options, + logError + }, 250) + + await vi.advanceTimersByTimeAsync(0) + expect(state.runtimeRequest).toHaveBeenCalledTimes(1) + expect(logError).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(500) + expect(state.runtimeRequest).toHaveBeenCalledTimes(2) + expect(logError).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(250) + expect(state.runtimeRequest).toHaveBeenCalledTimes(3) + expect(logError).toHaveBeenCalledTimes(2) + stop() + } finally { + vi.useRealTimers() + } + }) + it('dispatches fire-and-forget guest notifications through the broker route', async () => { const state = fixture() const record = state.viewSessions.create({ diff --git a/src/main/ipc/register-extension-ipc-handlers.ts b/src/main/ipc/register-extension-ipc-handlers.ts index 73abdc1db..003da84d2 100644 --- a/src/main/ipc/register-extension-ipc-handlers.ts +++ b/src/main/ipc/register-extension-ipc-handlers.ts @@ -139,6 +139,48 @@ export type ExtensionIpcRegistration = { dispose(): void } +const EXTENSION_PUMP_MAX_BACKOFF_MS = 30_000 +const EXTENSION_PUMP_LOG_REMINDER_MS = 30_000 + +function createExtensionPumpBackoff( + intervalMs: number, + logFailure: (message: string) => void +): { + delayMs(): number + fail(error: unknown): void + reset(): void +} { + const baseDelayMs = Math.max(250, intervalMs) + let consecutiveFailures = 0 + let lastFailureMessage = '' + let lastLoggedAt = 0 + return { + delayMs: () => Math.min( + EXTENSION_PUMP_MAX_BACKOFF_MS, + baseDelayMs * (2 ** Math.min(consecutiveFailures, 8)) + ), + fail: (error) => { + consecutiveFailures += 1 + const message = error instanceof Error ? error.message : String(error) + const now = Date.now() + if ( + message !== lastFailureMessage || + lastLoggedAt === 0 || + now - lastLoggedAt >= EXTENSION_PUMP_LOG_REMINDER_MS + ) { + logFailure(message) + lastFailureMessage = message + lastLoggedAt = now + } + }, + reset: () => { + consecutiveFailures = 0 + lastFailureMessage = '' + lastLoggedAt = 0 + } + } +} + export function startExtensionSecretRevealConsentPump( options: RegisterExtensionIpcHandlersOptions, intervalMs = 750 @@ -149,10 +191,13 @@ export function startExtensionSecretRevealConsentPump( const handled = new Set() const pendingDecisions = new Map() const nativeDialogs = options.nativeDialogs ?? new NativeDialogCoordinator() + const backoff = createExtensionPumpBackoff(intervalMs, (message) => { + options.logError?.('extension-account', 'Secret reveal consent pump failed.', { message }) + }) const schedule = (): void => { if (disposed) return - timer = setTimeout(() => void poll(), Math.max(250, intervalMs)) + timer = setTimeout(() => void poll(), backoff.delayMs()) timer.unref?.() } const poll = async (): Promise => { @@ -162,7 +207,11 @@ export function startExtensionSecretRevealConsentPump( const parent = options.getMainWindow() if (!parent || parent.isDestroyed()) return const result = await options.runtimeRequest('/v1/extensions/secret-reveal-requests', 'GET') - if (!result.ok) return + if (!result.ok) { + backoff.fail(new Error(`runtime request failed with HTTP ${result.status}`)) + return + } + backoff.reset() const payload = safeJsonParse(result.body) if (!isRecord(payload) || !Array.isArray(payload.requests)) return const request = payload.requests.find((candidate) => { @@ -219,9 +268,7 @@ export function startExtensionSecretRevealConsentPump( handled.add(requestId) } } catch (error) { - options.logError?.('extension-account', 'Secret reveal consent pump failed.', { - message: error instanceof Error ? error.message : String(error) - }) + backoff.fail(error) } finally { polling = false schedule() @@ -247,9 +294,12 @@ export function startExtensionNotificationPump( let polling = false let timer: NodeJS.Timeout | undefined let hadNotifications = false + const backoff = createExtensionPumpBackoff(intervalMs, (message) => { + options.logError?.('extension-notification', 'Extension notification pump failed.', { message }) + }) const schedule = (): void => { if (disposed) return - timer = setTimeout(() => void poll(), Math.max(250, intervalMs)) + timer = setTimeout(() => void poll(), backoff.delayMs()) timer.unref?.() } const poll = async (): Promise => { @@ -262,7 +312,11 @@ export function startExtensionNotificationPump( '/v1/extensions/workbench/notifications', 'GET' ) - if (!result.ok) return + if (!result.ok) { + backoff.fail(new Error(`runtime request failed with HTTP ${result.status}`)) + return + } + backoff.reset() const parsed = extensionNotificationSnapshotResponseSchema.safeParse(safeJsonParse(result.body)) if (!parsed.success) { options.logError?.('extension-notification', 'Kun returned an invalid notification snapshot.', { @@ -280,9 +334,7 @@ export function startExtensionNotificationPump( hadNotifications = payload.notifications.length > 0 parent.webContents.send('extension:notifications', payload) } catch (error) { - options.logError?.('extension-notification', 'Extension notification pump failed.', { - message: error instanceof Error ? error.message : String(error) - }) + backoff.fail(error) } finally { polling = false schedule() diff --git a/src/main/packaging-config.test.ts b/src/main/packaging-config.test.ts index 46a9e4f0a..960dc3ce0 100644 --- a/src/main/packaging-config.test.ts +++ b/src/main/packaging-config.test.ts @@ -471,6 +471,7 @@ describe('electron-builder Kun packaging', () => { join(process.cwd(), 'build/windows-installer-migration.ps1'), 'utf8' ) + const updaterSource = readFileSync(join(process.cwd(), 'src/main/gui-updater.ts'), 'utf8') expect(builderConfig.appId).toBe('com.xingyuzhong.deepseekgui') expect(builderConfig.productName).toBe('Kun') @@ -489,20 +490,28 @@ describe('electron-builder Kun packaging', () => { expect(installerScript).toContain('Var /GLOBAL KunInstallerSecondarySourceDir') expect(installerScript).toContain('Var /GLOBAL KunInstallerTargetDir') expect(installerScript).toContain('Var /GLOBAL KunInstallerSnapshotMode') + expect(installerScript).toContain('Var /GLOBAL KunInstallerPrimarySourceStale') + expect(installerScript).toContain('Var /GLOBAL KunInstallerSecondarySourceStale') + expect(installerScript).toContain('Var /GLOBAL KunInstallerCandidateExplicit') + expect(installerScript).toContain('Function KunSelectAutomaticUpdateMode') + expect(installerScript).toContain('!insertmacro kunRunMigrationHelper ResolveUpdateScope') + expect(installerScript).toContain('KUN_INSTALLER_CURRENT_USER_SOURCE') + expect(installerScript).toContain('KUN_INSTALLER_ALL_USERS_SOURCE') + expect(installerScript).toContain('KUN_INSTALLER_CANDIDATE_EXPLICIT') + expect(installerScript).toContain('KUN_INSTALLER_CANONICAL_LEAF') + expect(installerScript).toContain('KUN_INSTALLER_APP_EXECUTABLE') + expect(installerScript).toContain('KUN_INSTALLER_PRODUCT_NAME') + expect(installerScript).toContain('KUN_INSTALLER_INSTALL_MODE') + expect(installerScript).toContain('KUN_INSTALLER_APP_GUID') expect(installerScript).toContain('Call KunRefreshInstallPaths') expect(installerScript).toContain( 'ReadRegStr $R9 HKEY_CURRENT_USER "${UNINSTALL_REGISTRY_KEY}" UninstallString' ) expect(installerScript).toContain('Function KunReadMigrationResult') expect(installerScript).toContain('IfErrors KunMigrationResultMissing') - expect(installerScript).toContain('Function KunGetInQuotes') - expect(installerScript).toContain('Function KunGetFileParent') - expect(installerScript).toContain('Function KunRecoverSourceFromUninstallString') - expect(installerScript).toContain('Call KunGetInQuotes') - expect(installerScript).toContain('Call KunGetFileParent') - expect(installerScript).not.toContain('!insertmacro GetInQuotes') - expect(installerScript).not.toContain('Call GetFileParent') - expect(installerScript).not.toContain('!insertmacro kunRunMigrationHelper ResolveSource') + expect(installerScript).toContain('Function KunResolveRegisteredSource') + expect(installerScript).toContain('!insertmacro kunRunMigrationHelper ResolveSource') + expect(installerScript).toContain('KUN_INSTALLER_UNINSTALL_STRING') expect(installerScript).toContain('${if} $KunInstallerSnapshotMode != $installMode') expect(installerScript).toContain('${andIf} $installMode != "all"') expect(installerScript).not.toContain('KUN_INSTALLER_RESULT') @@ -516,6 +525,7 @@ describe('electron-builder Kun packaging', () => { expect(installerScript).toContain('customCheckAppRunning') expect(installerScript).toContain('customUnInstallCheck') expect(installerScript).toContain('customUnInstallCheckCurrentUser') + expect(installerScript).toContain('Function KunRetireSelectedShellState') expect(installerScript).toContain('Function KunRetireCurrentUserShellState') expect(installerScript).toContain( 'ReadRegStr $KunInstallerCurrentUserShortcutName HKEY_CURRENT_USER "${INSTALL_REGISTRY_KEY}" ShortcutName' @@ -531,6 +541,12 @@ describe('electron-builder Kun packaging', () => { 'DeleteRegKey HKEY_CURRENT_USER "${INSTALL_REGISTRY_KEY}"' ) expect(installerScript).toContain('KunHandleOldUninstallerResult') + expect(installerScript).toContain('Function KunSecureSelectedUninstallRegistration') + expect(installerScript).toContain('Function KunSecureCurrentUserUninstallRegistration') + expect(installerScript).toContain('!insertmacro kunRunMigrationHelper ResolveUninstaller') + expect(installerScript).toContain( + 'WriteRegStr SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" UninstallString' + ) expect(installerScript).toContain('FallbackCleanup') expect(installerScript).toContain('Restore') expect(installerScript).toContain('UpdatePath') @@ -539,11 +555,17 @@ describe('electron-builder Kun packaging', () => { expect(installerScript).toContain('${ifNot} ${isUpdated}') expect(installerScript).toContain('MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)"') expect(installerScript).not.toContain('RMDir /r "$INSTDIR"') - expect(installerScript).not.toContain('DeleteRegKey SHELL_CONTEXT') + expect(installerScript).toContain('DeleteRegKey SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}"') + expect(installerScript).toContain('DeleteRegKey SHELL_CONTEXT "${INSTALL_REGISTRY_KEY}"') + expect(installerScript).toContain('$KunInstallerPrimarySourceStale != 1') + expect(installerScript).toContain('$KunInstallerSecondarySourceStale != 1') + expect(installerScript).toContain('without modifying $KunInstallerPrimarySourceDir') + expect(installerScript).toContain('KUN_INSTALLER_PRIMARY_SOURCE_STALE') + expect(installerScript).toContain('KUN_INSTALLER_SECONDARY_SOURCE_STALE') expect(installerScript).not.toContain('Stop-Process -Id') - expect(migrationScript).toContain("ValidateSet('ResolvePath', 'ResolveSource', 'StopProcesses', 'Recover', 'Prepare'") - expect(migrationScript).toContain('old-uninstaller.exe') + expect(migrationScript).toContain("'ResolveUpdateScope', 'ResolveUninstaller', 'StopProcesses'") + expect(migrationScript).not.toContain("'old-uninstaller.exe'") expect(migrationScript).toContain("Join-Path $PSScriptRoot 'kun-windows-installer-result.txt'") expect(migrationScript).toContain('function Test-AppOwnedProcessPath') expect(migrationScript).toContain('Test-AppOwnedProcessPath $_.ExecutablePath $Roots') @@ -556,6 +578,13 @@ describe('electron-builder Kun packaging', () => { expect(migrationScript).toContain('Test-KnownApplicationEntry') expect(migrationScript).toContain('function Assert-NoReparsePathComponents') expect(migrationScript).toContain('function Assert-ApplicationSourceIdentity') + expect(migrationScript).toContain('function Test-RecoverableApplicationSource') + expect(migrationScript).toContain('function Assert-RecoverableApplicationSource') + expect(migrationScript).toContain('function Test-AppSpecificUninstaller') + expect(migrationScript).toContain('not a verifiable Kun installation') + expect(migrationScript).toContain('Write-InstallerResult ([string]$staleSourceMask)') + expect(migrationScript).toContain("Get-EnvironmentValue 'KUN_INSTALLER_PRIMARY_SOURCE_STALE'") + expect(migrationScript).toContain('$pathSources += Join-Path $source (Get-CanonicalLeaf)') expect(migrationScript).toContain('function Assert-FallbackCleanupSource') expect(migrationScript).toContain('The cleanup source does not match the preservation journal') expect(migrationScript).toContain('function Write-InstallerDiagnostic') @@ -563,6 +592,10 @@ describe('electron-builder Kun packaging', () => { expect(migrationScript).toContain('$preparedSources += @{') expect(migrationScript).toContain('if ($set.Unknown.Count -eq 0)') expect(migrationScript).toContain('function Assert-TrustedSecondarySource') + expect(migrationScript).toContain('function Assert-PackagedApplicationPayload') + expect(migrationScript).toContain("Join-Path $Source 'resources'") + expect(migrationScript).toContain("'app.asar'") + expect(migrationScript).toContain('Ignoring missing current-user installation source') expect(migrationScript).toContain('function Assert-NoReparsePointsInTree') expect(migrationScript).toContain( "Assert-NoReparsePointsInTree $directory 'Recognized application directory'" @@ -570,8 +603,21 @@ describe('electron-builder Kun packaging', () => { expect(migrationScript).toContain('Get-ValidatedJournalRecord') expect(migrationScript).toContain("Get-EnvironmentValue 'KUN_INSTALLER_SECONDARY_SOURCE'") expect(migrationScript).toContain('Invoke-RestoreJournal') + expect(migrationScript).toContain('function Resolve-AutomaticUpdateScope') + expect(migrationScript).toContain('function Resolve-TrustedAppUninstaller') + expect(migrationScript).toContain('function Assert-TargetVolumeReadyAndWritable') + expect(migrationScript).toContain('Assert-TargetVolumeReadyAndWritable $target') + expect(migrationScript).toContain('function Assert-JournalStorageTrusted') + expect(migrationScript).toContain('function Assert-JournalContext') + expect(migrationScript).toContain('$Journal.SchemaVersion = 3') + expect(migrationScript).toContain('function Get-ApplicationIdentityFiles') + expect(migrationScript).toContain('function Get-AppSpecificUninstallerFiles') expect(migrationScript).toContain("[Environment]::GetEnvironmentVariable('Path', 'User')") expect(migrationScript).not.toMatch(/Remove-Item[^\n]*(?:APPDATA|USERPROFILE|\.kun|\.deepseekgui)/i) + + expect(updaterSource).toContain("const WINDOWS_INSTALLER_UPDATE_SOURCE_ENV = 'KUN_INSTALLER_UPDATE_SOURCE'") + expect(updaterSource).toContain('restoreInstallerUpdateSource = setWindowsInstallerUpdateSource()') + expect(updaterSource).toContain('autoUpdater.quitAndInstall(true, true)') }) it('builds kun-dv with an isolated application identity and no production updater feed', () => { diff --git a/src/main/provider-quota.test.ts b/src/main/provider-quota.test.ts index f8208b99b..b347df93e 100644 --- a/src/main/provider-quota.test.ts +++ b/src/main/provider-quota.test.ts @@ -534,6 +534,9 @@ describe('provider quota registry and refresh', () => { expect(classifyProviderQuotaProbe( subscriptionProvider('gemini-subscription', 'antigravity-cli') )?.kind).toBe('antigravity-subscription') + expect(classifyProviderQuotaProbe( + subscriptionProvider('gemini-subscription-2', 'antigravity-cli') + )?.kind).toBe('antigravity-subscription') expect(classifyProviderQuotaProbe({ ...subscriptionProvider('claude-subscription', 'agent-sdk'), kind: 'http' @@ -751,6 +754,157 @@ describe('provider quota registry and refresh', () => { expect(JSON.stringify(result)).not.toMatch(/codex-secret|kimi-secret|grok-secret/) }) + it('refreshes an expired configured Codex login before querying quota', async () => { + const refreshFetch = vi.fn(async (url: string | URL) => { + expect(String(url)).toBe('https://auth.openai.com/oauth/token') + return Response.json({ + access_token: 'codex-refreshed-access', + refresh_token: 'codex-refreshed-refresh', + expires_in: 3_600 + }) + }) + vi.stubGlobal('fetch', refreshFetch) + const fetcher = vi.fn(async (_url: string | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers) + expect(headers.get('authorization')).toBe('Bearer codex-refreshed-access') + expect(headers.get('chatgpt-account-id')).toBe('acct-refresh') + expect(headers.get('user-agent')).toMatch(/^codex_cli_rs\//) + return Response.json({ + plan_type: 'plus', + rate_limit: { + primary_window: { + used_percent: 21, + reset_at: 1_900_000_000, + limit_window_seconds: 18_000 + } + } + }) + }) + + try { + const codex = subscriptionProvider('codex', 'http') + codex.apiKey = JSON.stringify({ + kind: 'codex-oauth', + accessToken: 'codex-expired-access', + refreshToken: 'codex-expired-refresh', + expiresAt: Date.now() - 60_000, + accountId: 'acct-refresh' + }) + const result = await listProviderQuotas(settings([codex]), fetcher) + + expect(result.entries.find((entry) => entry.providerId === 'codex')).toMatchObject({ + providerId: 'codex', + status: 'available', + metrics: [expect.objectContaining({ id: 'primary', usedPercent: 21 })] + }) + expect(refreshFetch).toHaveBeenCalledTimes(1) + expect(fetcher).toHaveBeenCalledTimes(1) + } finally { + vi.unstubAllGlobals() + } + }) + + it('refreshes and retries once when Codex rejects a current access token', async () => { + const resolveCodexCredential = vi.fn(async ( + _provider: ModelProviderProfileV1, + rejectedAccessToken?: string + ) => rejectedAccessToken + ? { accessToken: 'codex-retry-access', accountId: 'acct-retry' } + : { accessToken: 'codex-rejected-access', accountId: 'acct-retry' }) + const fetcher = vi.fn(async (_url: string | URL, init?: RequestInit) => { + const authorization = new Headers(init?.headers).get('authorization') + if (authorization === 'Bearer codex-rejected-access') { + return new Response('expired', { status: 401 }) + } + expect(authorization).toBe('Bearer codex-retry-access') + return Response.json({ + plan_type: 'pro', + rate_limit: { + primary_window: { + used_percent: 7, + reset_at: 1_900_000_000, + limit_window_seconds: 18_000 + } + } + }) + }) + + const result = await listProviderQuotas(settings([ + subscriptionProvider('codex', 'http') + ]), fetcher, { resolveCodexCredential }) + + expect(result.entries.find((entry) => entry.providerId === 'codex')).toMatchObject({ + providerId: 'codex', + status: 'available', + metrics: [expect.objectContaining({ id: 'primary', usedPercent: 7 })] + }) + expect(resolveCodexCredential).toHaveBeenNthCalledWith(2, expect.anything(), 'codex-rejected-access') + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it('refreshes and retries once when Grok rejects a current access token', async () => { + const resolveGrokCredential = vi.fn(async ( + _provider: ModelProviderProfileV1, + rejectedAccessToken?: string + ) => rejectedAccessToken + ? { accessToken: 'grok-retry-access' } + : { accessToken: 'grok-rejected-access' }) + const fetcher = vi.fn(async (_url: string | URL, init?: RequestInit) => { + const authorization = new Headers(init?.headers).get('authorization') + if (authorization === 'Bearer grok-rejected-access') { + return new Response('expired', { status: 401 }) + } + expect(authorization).toBe('Bearer grok-retry-access') + return new Response(grokBillingFrame(18, 1_900_000_000), { + headers: { 'Content-Type': 'application/grpc-web+proto' } + }) + }) + + const result = await listProviderQuotas(settings([ + subscriptionProvider('grok-subscription', 'http') + ]), fetcher, { resolveGrokCredential }) + + expect(result.entries.find((entry) => entry.providerId === 'grok-subscription')).toMatchObject({ + status: 'available', + metrics: [expect.objectContaining({ id: 'credits', usedPercent: 18 })] + }) + expect(resolveGrokCredential).toHaveBeenNthCalledWith( + 2, + expect.anything(), + 'grok-rejected-access' + ) + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it('reports the Gemini CLI migration reason instead of a generic authorization error', async () => { + const fetcher = vi.fn(async (url: string | URL, init?: RequestInit) => { + expect(String(url)).toContain(':loadCodeAssist') + const headers = new Headers(init?.headers) + expect(headers.get('user-agent')).toBe('google-gemini-cli') + expect(JSON.parse(String(init?.body))).toMatchObject({ + metadata: { ideType: 'IDE_UNSPECIFIED', pluginType: 'GEMINI' } + }) + return Response.json({ + allowedTiers: [{ id: 'standard-tier' }], + ineligibleTiers: [{ + reasonMessage: 'This client is no longer supported. Migrate to Antigravity.' + }] + }) + }) + + const result = await listProviderQuotas(settings([ + subscriptionProvider('gemini-cli-subscription', 'gemini-cli-api') + ]), fetcher, { + resolveGeminiCliToken: async () => 'gemini-cli-access' + }) + + expect(result.entries.find((entry) => entry.providerId === 'gemini-cli-subscription')).toMatchObject({ + status: 'error', + message: 'This client is no longer supported. Migrate to Antigravity.' + }) + expect(fetcher).toHaveBeenCalledTimes(1) + }) + it('reports a missing subscription login without making a request', async () => { const fetcher = vi.fn() const result = await listProviderQuotas(settings([ diff --git a/src/main/provider-quota.ts b/src/main/provider-quota.ts index 565a19a74..521425c9f 100644 --- a/src/main/provider-quota.ts +++ b/src/main/provider-quota.ts @@ -108,14 +108,14 @@ export function classifyProviderQuotaProbe( dashboardUrl: 'https://cursor.com/dashboard?tab=usage' } } - if (stableId === 'gemini-subscription' && provider.kind === 'antigravity-cli') { + if (provider.kind === 'antigravity-cli') { return { kind: 'antigravity-subscription', source: 'Google Antigravity quota API', dashboardUrl: 'https://antigravity.google' } } - if (stableId === 'gemini-cli-subscription' && provider.kind === 'gemini-cli-api') { + if (provider.kind === 'gemini-cli-api') { return { kind: 'gemini-cli-subscription', source: 'Google Gemini CLI quota API', diff --git a/src/main/provider-subscription-quota.ts b/src/main/provider-subscription-quota.ts index e2bbc7d69..c8faa7744 100644 --- a/src/main/provider-subscription-quota.ts +++ b/src/main/provider-subscription-quota.ts @@ -9,16 +9,30 @@ import type { ProviderQuotaMetric } from '../shared/provider-quota' import { GeminiCliOAuthSource } from '../../kun/src/adapters/model/gemini-cli-oauth.js' +import { geminiCliRequestHeaders } from '../../kun/src/adapters/model/provider-cli-identity.js' import { readOpenCodeGoLocalQuota, type OpenCodeGoLocalQuotaResult } from '../../kun/src/services/opencode-go-local-quota.js' -import { parseCodexCredentials } from './codex-auth' -import { parseGrokCredentials } from './grok-auth' +import { + codexUserAgent, + parseCodexCredentials, + refreshCodexToken, + type CodexOAuthCredentials +} from './codex-auth' +import { + isGrokCredentialExpired, + parseGrokCredentials, + refreshGrokToken, + type GrokOAuthCredentials +} from './grok-auth' const execFileAsync = promisify(execFile) const SUBSCRIPTION_QUOTA_TIMEOUT_MS = 12_000 const SUBSCRIPTION_QUOTA_MAX_RESPONSE_BYTES = 256 * 1024 +const CODEX_QUOTA_EARLY_REFRESH_MS = 5 * 60 * 1000 +const codexQuotaCredentialCache = new Map() +const grokQuotaCredentialCache = new Map() export type SubscriptionQuotaProbeKind = | 'claude-subscription' @@ -61,8 +75,14 @@ type GoogleQuotaCredential = { export type SubscriptionQuotaRuntime = { resolveClaudeToken(provider: ModelProviderProfileV1): Promise - resolveCodexCredential(provider: ModelProviderProfileV1): Promise - resolveGrokCredential(provider: ModelProviderProfileV1): Promise + resolveCodexCredential( + provider: ModelProviderProfileV1, + rejectedAccessToken?: string + ): Promise + resolveGrokCredential( + provider: ModelProviderProfileV1, + rejectedAccessToken?: string + ): Promise resolveCursorSession(): Promise resolveAntigravityCredential( context: SubscriptionProbeContext @@ -78,6 +98,13 @@ export class ProviderQuotaMissingCredentialError extends Error { } } +class ProviderQuotaAuthorizationError extends Error { + constructor(readonly status: number) { + super('The provider did not authorize quota access for the existing login.') + this.name = 'ProviderQuotaAuthorizationError' + } +} + export async function runSubscriptionQuotaProbe( kind: SubscriptionQuotaProbeKind, provider: ModelProviderProfileV1, @@ -108,38 +135,51 @@ export async function runSubscriptionQuotaProbe( } } if (kind === 'codex-subscription') { - const credential = await runtime.resolveCodexCredential(provider) + let credential = await runtime.resolveCodexCredential(provider) if (!credential) { throw new ProviderQuotaMissingCredentialError( 'Connect the ChatGPT subscription in Settings or sign in with Codex CLI.' ) } - return parseCodexSubscriptionQuota(await requestSubscriptionJson( - 'https://chatgpt.com/backend-api/wham/usage', - { - headers: { - Accept: 'application/json', - Authorization: `Bearer ${credential.accessToken}`, - 'User-Agent': 'Kun' - }, - ...(credential.accountId - ? { extraHeaders: { 'ChatGPT-Account-Id': credential.accountId } } - : {}) - }, - context - )) + try { + return parseCodexSubscriptionQuota(await requestCodexSubscriptionQuota(credential, context)) + } catch (error) { + if (!(error instanceof ProviderQuotaAuthorizationError)) throw error + const refreshed = await runtime.resolveCodexCredential(provider, credential.accessToken) + if (!refreshed || refreshed.accessToken === credential.accessToken) { + throw new ProviderQuotaMissingCredentialError( + 'The Codex login expired. Sign in to the ChatGPT subscription or Codex CLI again.' + ) + } + credential = refreshed + return parseCodexSubscriptionQuota(await requestCodexSubscriptionQuota(credential, context)) + } } if (kind === 'grok-subscription') { - const credential = await runtime.resolveGrokCredential(provider) + let credential = await runtime.resolveGrokCredential(provider) if (!credential) { throw new ProviderQuotaMissingCredentialError( 'Connect Grok in Settings or run `grok login` before refreshing quota.' ) } - const metrics = await probeGrokSubscriptionQuota(credential, context) - return { - metrics, - ...(credential.email ? { summary: credential.email } : {}) + try { + return { + metrics: await probeGrokSubscriptionQuota(credential, context), + ...(credential.email ? { summary: credential.email } : {}) + } + } catch (error) { + if (!(error instanceof ProviderQuotaAuthorizationError)) throw error + const refreshed = await runtime.resolveGrokCredential(provider, credential.accessToken) + if (!refreshed || refreshed.accessToken === credential.accessToken) { + throw new ProviderQuotaMissingCredentialError( + 'The Grok login expired. Connect Grok in Settings or run `grok login` again.' + ) + } + credential = refreshed + return { + metrics: await probeGrokSubscriptionQuota(credential, context), + ...(credential.email ? { summary: credential.email } : {}) + } } } if (kind === 'cursor-subscription') { @@ -167,7 +207,7 @@ export async function runSubscriptionQuotaProbe( 'Sign in to the official Antigravity app on this computer before refreshing quota.' ) } - return probeGoogleCodeAssistQuota(credential, context) + return probeGoogleCodeAssistQuota(credential, context, 'antigravity') } if (kind === 'opencode-go-local') { const quota = await runtime.resolveOpenCodeGoQuota() @@ -184,7 +224,7 @@ export async function runSubscriptionQuotaProbe( 'Run Gemini CLI and sign in with Google before refreshing quota.' ) } - return probeGoogleCodeAssistQuota({ accessToken }, context) + return probeGoogleCodeAssistQuota({ accessToken }, context, 'gemini-cli') } export function parseClaudeSubscriptionQuota(payload: unknown): ProviderQuotaMetric[] { @@ -473,13 +513,17 @@ async function probeGrokSubscriptionQuota( async function probeGoogleCodeAssistQuota( credential: GoogleQuotaCredential, - context: SubscriptionProbeContext + context: SubscriptionProbeContext, + client: 'antigravity' | 'gemini-cli' ): Promise<{ metrics: ProviderQuotaMetric[]; summary?: string }> { + const clientHeaders = client === 'gemini-cli' + ? geminiCliRequestHeaders() + : { 'User-Agent': 'antigravity' } const headers = { Accept: 'application/json', Authorization: `Bearer ${credential.accessToken}`, 'Content-Type': 'application/json', - 'User-Agent': 'antigravity' + ...clientHeaders } const setup = await requestSubscriptionJson( 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist', @@ -488,7 +532,7 @@ async function probeGoogleCodeAssistQuota( headers, body: JSON.stringify({ metadata: { - ideType: 'ANTIGRAVITY', + ideType: client === 'antigravity' ? 'ANTIGRAVITY' : 'IDE_UNSPECIFIED', platform: 'PLATFORM_UNSPECIFIED', pluginType: 'GEMINI' } @@ -502,6 +546,18 @@ async function probeGoogleCodeAssistQuota( ? projectValue.trim() : stringValue(optionalRecord(projectValue)?.id) || stringValue(optionalRecord(projectValue)?.projectId) + if (!project) { + const reason = Array.isArray(setupRecord.ineligibleTiers) + ? setupRecord.ineligibleTiers + .map((value) => stringValue(optionalRecord(value)?.reasonMessage)) + .filter(Boolean) + .join('; ') + : '' + throw new Error( + reason || + 'Google Code Assist account setup is incomplete. Finish provider onboarding and retry.' + ) + } const body = JSON.stringify(project ? { project } : {}) let quotaPayload: unknown try { @@ -553,40 +609,90 @@ async function resolveClaudeToken(provider: ModelProviderProfileV1): Promise { - const configured = parseCodexCredentials(provider.apiKey.trim()) - if (configured) { - return { - accessToken: configured.accessToken, - accountId: configured.accountId - } - } - const ambient = await readJsonFile(join( - configuredHomeDirectory('CODEX_HOME', '.codex'), - 'auth.json' - )) + const ambient = provider.apiKey.trim() + ? undefined + : await readJsonFile(join( + configuredHomeDirectory('CODEX_HOME', '.codex'), + 'auth.json' + )) const root = optionalRecord(ambient) const tokens = optionalRecord(root?.tokens) + const configured = parseCodexCredentials(provider.apiKey.trim()) const accessToken = stringValue(tokens?.access_token) || stringValue(tokens?.accessToken) - if (!accessToken) return undefined + const refreshToken = stringValue(tokens?.refresh_token) || stringValue(tokens?.refreshToken) + const accountId = stringValue(tokens?.account_id) || stringValue(tokens?.accountId) + const source = configured ?? ( + accessToken && refreshToken && accountId + ? { + kind: 'codex-oauth' as const, + accessToken, + refreshToken, + expiresAt: jwtExpiryMs(accessToken), + accountId + } + : undefined + ) + if (!source) { + if (!accessToken) return undefined + return { accessToken, ...(accountId ? { accountId } : {}) } + } + + const cached = codexQuotaCredentialCache.get(source.refreshToken) + const credential = cached ?? source + const rejectedCurrentToken = Boolean( + rejectedAccessToken && credential.accessToken === rejectedAccessToken + ) + const expiresSoon = !Number.isFinite(credential.expiresAt) || + credential.expiresAt <= 0 || + Date.now() >= credential.expiresAt - CODEX_QUOTA_EARLY_REFRESH_MS + if (!rejectedCurrentToken && !expiresSoon) return codexQuotaCredential(credential) + + const refreshed = await refreshCodexToken(credential) + if (!refreshed) { + if (!rejectedCurrentToken && Date.now() < credential.expiresAt) { + return codexQuotaCredential(credential) + } + return undefined + } + codexQuotaCredentialCache.set(source.refreshToken, refreshed) + codexQuotaCredentialCache.set(refreshed.refreshToken, refreshed) + return codexQuotaCredential(refreshed) +} + +function codexQuotaCredential(credentials: CodexOAuthCredentials): CodexQuotaCredential { return { - accessToken, - ...(stringValue(tokens?.account_id) || stringValue(tokens?.accountId) - ? { accountId: stringValue(tokens?.account_id) || stringValue(tokens?.accountId) } - : {}) + accessToken: credentials.accessToken, + accountId: credentials.accountId + } +} + +function jwtExpiryMs(token: string): number { + const body = token.split('.')[1] + if (!body) return 0 + try { + const claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as Record + return typeof claims.exp === 'number' && Number.isFinite(claims.exp) + ? claims.exp * 1_000 + : 0 + } catch { + return 0 } } async function resolveGrokCredential( - provider: ModelProviderProfileV1 + provider: ModelProviderProfileV1, + rejectedAccessToken?: string ): Promise { const configured = parseGrokCredentials(provider.apiKey.trim()) - if (configured && configured.expiresAt > Date.now()) { - return { - accessToken: configured.accessToken, - ...(configured.email ? { email: configured.email } : {}) - } + if (configured) { + return refreshableGrokQuotaCredential(configured, rejectedAccessToken) + } + const configuredToken = provider.apiKey.trim() + if (configuredToken && !configuredToken.startsWith('{') && configuredToken !== rejectedAccessToken) { + return { accessToken: configuredToken } } const ambient = optionalRecord(await readJsonFile(join( @@ -611,10 +717,26 @@ async function resolveGrokCredential( for (const [, rawEntry] of candidates) { const entry = optionalRecord(rawEntry) const accessToken = stringValue(entry?.key) - if (!accessToken) continue - const expiresAt = isoDateValue(entry?.expires_at) - if (expiresAt && new Date(expiresAt).getTime() <= Date.now()) continue + if (!accessToken || accessToken === rejectedAccessToken) continue const email = stringValue(entry?.email) + const refreshToken = stringValue(entry?.refresh_token) + const expiresAtValue = isoDateValue(entry?.expires_at) + const expiresAt = expiresAtValue ? new Date(expiresAtValue).getTime() : jwtExpiryMs(accessToken) + if (refreshToken) { + const credential = await refreshableGrokQuotaCredential({ + kind: 'grok-oauth', + accessToken, + refreshToken, + expiresAt, + ...(email ? { email } : {}), + ...(stringValue(entry?.user_id) ? { userId: stringValue(entry?.user_id) } : {}), + ...(stringValue(entry?.oidc_issuer) ? { issuer: stringValue(entry?.oidc_issuer) } : {}), + ...(stringValue(entry?.oidc_client_id) ? { clientId: stringValue(entry?.oidc_client_id) } : {}) + }, rejectedAccessToken) + if (credential) return credential + continue + } + if (expiresAt > 0 && expiresAt <= Date.now()) continue return { accessToken, ...(email ? { email } : {}) @@ -623,6 +745,37 @@ async function resolveGrokCredential( return undefined } +async function refreshableGrokQuotaCredential( + source: GrokOAuthCredentials, + rejectedAccessToken?: string +): Promise { + const cached = grokQuotaCredentialCache.get(source.refreshToken) + const credential = cached ?? source + const rejectedCurrentToken = Boolean( + rejectedAccessToken && credential.accessToken === rejectedAccessToken + ) + if (!rejectedCurrentToken && !isGrokCredentialExpired(credential)) { + return grokQuotaCredential(credential) + } + const refreshed = await refreshGrokToken(credential) + if (!refreshed) { + if (!rejectedCurrentToken && credential.expiresAt > Date.now()) { + return grokQuotaCredential(credential) + } + return undefined + } + grokQuotaCredentialCache.set(source.refreshToken, refreshed) + grokQuotaCredentialCache.set(refreshed.refreshToken, refreshed) + return grokQuotaCredential(refreshed) +} + +function grokQuotaCredential(credentials: GrokOAuthCredentials): GrokQuotaCredential { + return { + accessToken: credentials.accessToken, + ...(credentials.email ? { email: credentials.email } : {}) + } +} + async function resolveCursorSession(): Promise { const dbPath = process.platform === 'darwin' ? join(homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb') @@ -787,10 +940,27 @@ async function refreshAntigravityAccessToken( type SubscriptionRequestInput = { method?: string headers: Record - extraHeaders?: Record body?: BodyInit } +function requestCodexSubscriptionQuota( + credential: CodexQuotaCredential, + context: SubscriptionProbeContext +): Promise { + return requestSubscriptionJson( + 'https://chatgpt.com/backend-api/wham/usage', + { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${credential.accessToken}`, + 'User-Agent': codexUserAgent(), + ...(credential.accountId ? { 'ChatGPT-Account-Id': credential.accountId } : {}) + } + }, + context + ) +} + async function requestSubscriptionResponse( url: string, input: SubscriptionRequestInput, @@ -800,7 +970,7 @@ async function requestSubscriptionResponse( try { response = await context.fetcher(url, { method: input.method ?? 'GET', - headers: { ...input.headers, ...input.extraHeaders }, + headers: input.headers, ...(input.body === undefined ? {} : { body: input.body }), signal: AbortSignal.timeout(SUBSCRIPTION_QUOTA_TIMEOUT_MS) }, context.proxyUrl) @@ -812,7 +982,7 @@ async function requestSubscriptionResponse( } if (!response.ok) { if (response.status === 401 || response.status === 403) { - throw new Error('The provider did not authorize quota access for the existing login.') + throw new ProviderQuotaAuthorizationError(response.status) } throw new Error(`The provider quota endpoint returned HTTP ${response.status}.`) } diff --git a/src/main/runtime-provider-quota.test.ts b/src/main/runtime-provider-quota.test.ts new file mode 100644 index 000000000..32a79574f --- /dev/null +++ b/src/main/runtime-provider-quota.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' +import { requestRuntimeProviderQuotas } from './runtime-provider-quota' + +describe('requestRuntimeProviderQuotas', () => { + it('uses the canonical Kun quota endpoint and validates its response', async () => { + const runtimeRequest = vi.fn(async () => ({ + ok: true, + status: 200, + body: JSON.stringify({ + entries: [{ + providerId: 'grok-subscription', + providerName: 'Grok', + status: 'available', + metrics: [{ id: 'credits', label: 'Credits usage', unit: 'percent', usedPercent: 12 }] + }], + refreshedAt: '2026-08-03T08:00:00.000Z' + }) + })) + + await expect(requestRuntimeProviderQuotas(runtimeRequest)).resolves.toMatchObject({ + entries: [{ providerId: 'grok-subscription', status: 'available' }] + }) + expect(runtimeRequest).toHaveBeenCalledWith('/v1/provider-quotas', 'GET') + }) + + it('does not expose an arbitrary runtime error body', async () => { + const runtimeRequest = vi.fn(async () => ({ + ok: false, + status: 500, + body: 'not-json secret upstream response' + })) + + await expect(requestRuntimeProviderQuotas(runtimeRequest)).rejects.toThrow( + 'Kun returned malformed provider quota data.' + ) + }) +}) diff --git a/src/main/runtime-provider-quota.ts b/src/main/runtime-provider-quota.ts new file mode 100644 index 000000000..58b8139ef --- /dev/null +++ b/src/main/runtime-provider-quota.ts @@ -0,0 +1,39 @@ +import { ProviderQuotaListResponseSchema } from '../../kun/src/contracts/provider-quota.js' +import type { RuntimeRequestResult } from '../shared/kun-gui-api' +import type { ProviderQuotaListResult } from '../shared/provider-quota' + +type ProviderQuotaRuntimeRequest = ( + path: string, + method?: string +) => Promise + +export async function requestRuntimeProviderQuotas( + runtimeRequest: ProviderQuotaRuntimeRequest +): Promise { + const response = await runtimeRequest('/v1/provider-quotas', 'GET') + let payload: unknown + try { + payload = JSON.parse(response.body) + } catch { + throw new Error('Kun returned malformed provider quota data.') + } + if (!response.ok) { + const message = runtimeErrorMessage(payload) + throw new Error(message || `Kun provider quota request failed (HTTP ${response.status}).`) + } + const parsed = ProviderQuotaListResponseSchema.safeParse(payload) + if (!parsed.success) { + throw new Error('Kun returned invalid provider quota data.') + } + return parsed.data +} + +function runtimeErrorMessage(payload: unknown): string { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return '' + const record = payload as Record + if (typeof record.message === 'string') return record.message.trim() + const error = record.error + if (!error || typeof error !== 'object' || Array.isArray(error)) return '' + const message = (error as Record).message + return typeof message === 'string' ? message.trim() : '' +} diff --git a/src/main/windows-installer-migration.test.ts b/src/main/windows-installer-migration.test.ts index 9e01a8133..3bf58b2b6 100644 --- a/src/main/windows-installer-migration.test.ts +++ b/src/main/windows-installer-migration.test.ts @@ -26,16 +26,29 @@ function makeTempRoot(): string { } function runHelper(input: { - action: 'ResolvePath' | 'ResolveSource' | 'Recover' | 'Prepare' | 'FallbackCleanup' | 'Restore' + action: 'ResolvePath' | 'ResolveSource' | 'ResolveUpdateScope' | 'ResolveUninstaller' | 'Recover' | 'Prepare' | 'FallbackCleanup' | 'Restore' source?: string secondary?: string + currentUserSource?: string + currentUserUninstallCommand?: string + allUsersSource?: string + allUsersUninstallCommand?: string + updateSource?: string candidate?: string + candidateExplicit?: boolean target?: string journal?: string resultPath?: string uninstallCommand?: string scriptPath?: string userProfile?: string + primarySourceStale?: boolean + secondarySourceStale?: boolean + installMode?: 'CurrentUser' | 'all' + appGuid?: string + canonicalLeaf?: string + appExecutable?: string + productName?: string }) { const systemRoot = process.env.SystemRoot ?? 'C:\\Windows' const powershell = join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') @@ -60,10 +73,23 @@ function runHelper(input: { ...(input.userProfile ? { USERPROFILE: input.userProfile } : {}), KUN_INSTALLER_SOURCE: input.source ?? '', KUN_INSTALLER_SECONDARY_SOURCE: input.secondary ?? '', + KUN_INSTALLER_CURRENT_USER_SOURCE: input.currentUserSource ?? '', + KUN_INSTALLER_CURRENT_USER_UNINSTALL_STRING: input.currentUserUninstallCommand ?? '', + KUN_INSTALLER_ALL_USERS_SOURCE: input.allUsersSource ?? '', + KUN_INSTALLER_ALL_USERS_UNINSTALL_STRING: input.allUsersUninstallCommand ?? '', + KUN_INSTALLER_UPDATE_SOURCE: input.updateSource ?? '', KUN_INSTALLER_CANDIDATE: input.candidate ?? '', + KUN_INSTALLER_CANDIDATE_EXPLICIT: input.candidateExplicit ? '1' : '0', KUN_INSTALLER_TARGET: input.target ?? '', KUN_INSTALLER_JOURNAL: input.journal ?? join(makeTempRoot(), 'journal.json'), KUN_INSTALLER_UNINSTALL_STRING: input.uninstallCommand ?? '', + KUN_INSTALLER_PRIMARY_SOURCE_STALE: input.primarySourceStale ? '1' : '0', + KUN_INSTALLER_SECONDARY_SOURCE_STALE: input.secondarySourceStale ? '1' : '0', + KUN_INSTALLER_INSTALL_MODE: input.installMode ?? 'CurrentUser', + KUN_INSTALLER_APP_GUID: input.appGuid ?? 'test-kun-app-guid', + KUN_INSTALLER_CANONICAL_LEAF: input.canonicalLeaf ?? 'Kun', + KUN_INSTALLER_APP_EXECUTABLE: input.appExecutable ?? 'Kun.exe', + KUN_INSTALLER_PRODUCT_NAME: input.productName ?? 'Kun', KUN_INSTALLER_SELF_PID: String(process.pid) } } @@ -74,6 +100,14 @@ function processError(result: ReturnType): string { return String(result.stderr ?? '') } +function unavailableDriveTarget(): string { + for (let code = 'Z'.charCodeAt(0); code >= 'P'.charCodeAt(0); code -= 1) { + const root = `${String.fromCharCode(code)}:\\` + if (!existsSync(root)) return `${root}Kun` + } + throw new Error('No unavailable drive letter was available for the installer helper test.') +} + function readJournal(path: string): { Records: Array<{ Stash: string }> } { return JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, '')) as { Records: Array<{ Stash: string }> @@ -106,6 +140,37 @@ windowsOnly('Windows installer migration helper', () => { expect(readFileSync(resultPath, 'utf16le')).toBe(expected) }) + it('lets an explicit target override a registered legacy branded source', () => { + const resultPath = join(makeTempRoot(), 'resolved-path.txt') + const result = runHelper({ + action: 'ResolvePath', + source: 'D:\\Legacy\\DeepSeek GUI', + candidate: 'E:\\Chosen Apps', + candidateExplicit: true, + resultPath + }) + + expect(result.status, processError(result)).toBe(0) + expect(readFileSync(resultPath, 'utf16le')).toBe('E:\\Chosen Apps\\Kun') + }) + + it('uses development-flavor identity without appending or cleaning production Kun', () => { + const root = makeTempRoot() + const candidate = join(root, 'kun-dv') + const resultPath = join(root, 'resolved-path.txt') + const result = runHelper({ + action: 'ResolvePath', + candidate, + resultPath, + canonicalLeaf: 'kun-dv', + appExecutable: 'kun-dv.exe', + productName: 'kun-dv' + }) + + expect(result.status, processError(result)).toBe(0) + expect(readFileSync(resultPath, 'utf16le')).toBe(candidate) + }) + it('writes a recovered install source to the explicit result path', () => { const root = makeTempRoot() const source = join(root, 'DeepSeek GUI') @@ -123,6 +188,79 @@ windowsOnly('Windows installer migration helper', () => { expect(readFileSync(resultPath, 'utf16le')).toBe(canonicalSource) }) + it('ignores a malformed install location when the uninstall command identifies a verified source', () => { + const root = makeTempRoot() + const source = join(root, 'Kun') + const resultPath = join(root, 'resolved-source.txt') + mkdirSync(source, { recursive: true }) + writeFileSync(join(source, 'Kun.exe'), 'app') + writeFileSync(join(source, 'Uninstall Kun.exe'), 'uninstaller') + + const result = runHelper({ + action: 'ResolveSource', + source: 'not-an-absolute-path', + uninstallCommand: `"${join(source, 'Uninstall Kun.exe')}" /currentuser`, + resultPath + }) + + expect(result.status, processError(result)).toBe(0) + expect(readFileSync(resultPath, 'utf16le')).toBe(realpathSync.native(source)) + }) + + it('selects the automatic-update scope that owns the running executable', () => { + const root = makeTempRoot() + const current = join(root, 'current', 'Kun') + const all = join(root, 'all', 'Kun') + const resultPath = join(root, 'scope.txt') + mkdirSync(current, { recursive: true }) + mkdirSync(all, { recursive: true }) + writeFileSync(join(current, 'Kun.exe'), 'current app') + writeFileSync(join(all, 'Kun.exe'), 'all-users app') + + const result = runHelper({ + action: 'ResolveUpdateScope', + currentUserSource: current, + allUsersSource: all, + updateSource: all, + resultPath + }) + + expect(result.status, processError(result)).toBe(0) + expect(readFileSync(resultPath, 'utf16le')).toBe('all') + }) + + it('rejects an ambiguous automatic-update source', () => { + const root = makeTempRoot() + const source = join(root, 'Kun') + mkdirSync(source, { recursive: true }) + writeFileSync(join(source, 'Kun.exe'), 'app') + + const result = runHelper({ + action: 'ResolveUpdateScope', + currentUserSource: source, + allUsersSource: source, + updateSource: source + }) + + expect(result.status).not.toBe(0) + expect(processError(result)).toContain('exactly one verified Kun registration') + }) + + it('returns only an app-specific uninstaller inside the verified source', () => { + const root = makeTempRoot() + const source = join(root, 'Kun') + const resultPath = join(root, 'trusted-uninstaller.txt') + mkdirSync(source, { recursive: true }) + writeFileSync(join(source, 'Kun.exe'), 'app') + writeFileSync(join(source, 'Uninstall Kun.exe'), 'uninstaller') + + const result = runHelper({ action: 'ResolveUninstaller', source, resultPath }) + + expect(result.status, processError(result)).toBe(0) + expect(readFileSync(resultPath, 'utf16le')) + .toBe(realpathSync.native(join(source, 'Uninstall Kun.exe'))) + }) + it('writes resolver output beside the helper without cross-process result state', () => { const root = makeTempRoot() const copiedHelper = join(root, 'migration.ps1') @@ -143,6 +281,108 @@ windowsOnly('Windows installer migration helper', () => { expect(readFileSync(resultPath, 'utf16le')).toBe(canonicalSource) }) + it('recovers the legacy parent of a falsely nested registered source', () => { + const root = makeTempRoot() + const source = join(root, 'DeepSeek GUI') + const nested = join(source, 'Kun') + const resultPath = join(root, 'resolved-source.txt') + mkdirSync(nested, { recursive: true }) + writeFileSync(join(source, 'DeepSeek GUI.exe'), 'app') + + const result = runHelper({ + action: 'ResolveSource', + source: nested, + resultPath, + uninstallCommand: `"${join(nested, 'Uninstall Kun.exe')}" /currentuser` + }) + + expect(result.status, processError(result)).toBe(0) + expect(readFileSync(resultPath, 'utf16le')).toBe(realpathSync.native(source)) + }) + + it('accepts a partially damaged packaged source with its app-specific uninstaller', () => { + const root = makeTempRoot() + const source = join(root, 'Kun') + const journal = join(root, 'recovery', 'journal.json') + const resultPath = join(root, 'prepare-result.txt') + mkdirSync(join(source, 'resources'), { recursive: true }) + writeFileSync(join(source, 'resources', 'app.asar'), 'packaged app') + writeFileSync(join(source, 'Uninstall Kun.exe'), 'uninstaller') + writeFileSync(join(source, 'personal.txt'), 'keep me') + + const prepared = runHelper({ action: 'Prepare', source, target: source, journal, resultPath }) + + expect(prepared.status, processError(prepared)).toBe(0) + expect(readFileSync(resultPath, 'utf16le')).toBe('0') + expect(existsSync(join(source, 'personal.txt'))).toBe(false) + const restored = runHelper({ action: 'Restore', source, target: source, journal }) + expect(restored.status, processError(restored)).toBe(0) + expect(readFileSync(join(source, 'personal.txt'), 'utf8')).toBe('keep me') + }) + + it.each([ + ['missing', false], + ['empty', true] + ])('classifies a %s registered source as stale without changing it', (_label, createSource) => { + const root = makeTempRoot() + const source = join(root, 'Kun') + const journal = join(root, 'recovery', 'journal.json') + const resultPath = join(root, 'prepare-result.txt') + if (createSource) mkdirSync(source, { recursive: true }) + + const prepared = runHelper({ action: 'Prepare', source, target: source, journal, resultPath }) + + expect(prepared.status, processError(prepared)).toBe(0) + expect(readFileSync(resultPath, 'utf16le')).toBe('1') + expect(existsSync(source)).toBe(createSource) + expect(existsSync(journal)).toBe(false) + if (createSource) { + const restored = runHelper({ + action: 'Restore', source, target: source, journal, primarySourceStale: true + }) + expect(restored.status, processError(restored)).toBe(0) + expect(existsSync(source)).toBe(true) + } + }) + + it('leaves an unverified non-empty registered source unchanged', () => { + const root = makeTempRoot() + const source = join(root, 'Kun') + const journal = join(root, 'recovery', 'journal.json') + mkdirSync(join(source, 'resources'), { recursive: true }) + writeFileSync(join(source, 'resources', 'app.asar'), 'ambiguous electron app') + writeFileSync(join(source, 'personal.txt'), 'keep me') + + const prepared = runHelper({ action: 'Prepare', source, target: source, journal }) + + expect(prepared.status).not.toBe(0) + expect(processError(prepared)).toContain('not a verifiable Kun installation') + expect(readFileSync(join(source, 'personal.txt'), 'utf8')).toBe('keep me') + expect(readFileSync(join(source, 'resources', 'app.asar'), 'utf8')).toBe('ambiguous electron app') + expect(existsSync(journal)).toBe(false) + }) + + it('rejects an unavailable target volume before changing the source', () => { + const root = makeTempRoot() + const source = join(root, 'Kun') + const journal = join(root, 'recovery', 'journal.json') + mkdirSync(join(source, 'resources'), { recursive: true }) + writeFileSync(join(source, 'Kun.exe'), 'app') + writeFileSync(join(source, 'personal.txt'), 'keep me') + + const result = runHelper({ + action: 'Prepare', + source, + target: unavailableDriveTarget(), + journal + }) + + expect(result.status).not.toBe(0) + expect(processError(result)).toContain('target volume is unavailable') + expect(readFileSync(join(source, 'personal.txt'), 'utf8')).toBe('keep me') + expect(existsSync(journal)).toBe(false) + }) + it('preserves unknown top-level content and restores it after fallback cleanup', () => { const root = makeTempRoot() const source = join(root, 'DeepSeek GUI') @@ -220,6 +460,63 @@ windowsOnly('Windows installer migration helper', () => { expect(readdirSync(source).sort()).toEqual(['Kun.exe', 'personal.txt']) }) + it('rejects a recovery journal whose application identity was changed', () => { + const root = makeTempRoot() + const source = join(root, 'Custom Install') + const journal = join(root, 'recovery', 'journal.json') + mkdirSync(source, { recursive: true }) + writeFileSync(join(source, 'Kun.exe'), 'app') + writeFileSync(join(source, 'personal.txt'), 'personal') + + const prepared = runHelper({ action: 'Prepare', source, target: source, journal }) + expect(prepared.status, processError(prepared)).toBe(0) + const journalData = JSON.parse(readFileSync(journal, 'utf8').replace(/^\uFEFF/, '')) as { + AppGuid: string + Records: Array<{ Stash: string }> + } + journalData.AppGuid = 'other-app-guid' + writeFileSync(journal, JSON.stringify(journalData, null, 2), 'utf8') + + const recovery = runHelper({ action: 'Recover', source, target: source, journal }) + + expect(recovery.status).not.toBe(0) + expect(processError(recovery)).toContain('application identity does not match') + expect(existsSync(join(source, 'personal.txt'))).toBe(false) + expect(readFileSync(join(journalData.Records[0].Stash, 'content', 'personal.txt'), 'utf8')) + .toBe('personal') + }) + + it('rejects an existing journal under a cross-user-writable directory', () => { + const root = makeTempRoot() + const source = join(root, 'Kun') + const recoveryDirectory = join(root, 'recovery') + const journal = join(recoveryDirectory, 'journal.json') + mkdirSync(source, { recursive: true }) + mkdirSync(recoveryDirectory, { recursive: true }) + writeFileSync(join(source, 'Kun.exe'), 'app') + writeFileSync(join(source, 'personal.txt'), 'do not move') + writeFileSync(journal, JSON.stringify({ + SchemaVersion: 3, + AppGuid: 'test-kun-app-guid', + InstallMode: 'current', + Target: source, + Records: [] + }), 'utf8') + const systemRoot = process.env.SystemRoot ?? 'C:\\Windows' + const icacls = spawnSync( + join(systemRoot, 'System32', 'icacls.exe'), + [recoveryDirectory, '/inheritance:r', '/grant:r', '*S-1-1-0:(OI)(CI)F'], + { encoding: 'utf8' } + ) + expect(icacls.status, String(icacls.stderr ?? '')).toBe(0) + + const recovery = runHelper({ action: 'Recover', source, target: source, journal }) + + expect(recovery.status).not.toBe(0) + expect(processError(recovery)).toContain('untrusted ACL') + expect(readFileSync(join(source, 'personal.txt'), 'utf8')).toBe('do not move') + }) + it('preserves registered per-user content alongside an all-users source', () => { const root = makeTempRoot() const source = join(root, 'Machine Kun') @@ -251,7 +548,37 @@ windowsOnly('Windows installer migration helper', () => { expect(readFileSync(join(secondary, 'personal.txt'), 'utf8')).toBe(secondary) }) - it('rejects a current-user secondary source outside the current user profile', () => { + it('preserves a verified external current-user installation during all-users migration', () => { + const root = makeTempRoot() + const userProfile = join(root, 'profile') + const source = join(root, 'Machine Kun') + const secondary = join(root, 'external-drive', 'Kun') + const journal = join(root, 'recovery', 'journal.json') + mkdirSync(userProfile, { recursive: true }) + for (const directory of [source, secondary]) { + mkdirSync(join(directory, 'resources'), { recursive: true }) + writeFileSync(join(directory, 'Kun.exe'), 'app') + } + writeFileSync(join(secondary, 'resources', 'app.asar'), 'packaged app') + writeFileSync(join(secondary, 'personal.txt'), 'keep external content') + + const prepared = runHelper({ + action: 'Prepare', source, target: source, journal, secondary, userProfile + }) + + expect(prepared.status, processError(prepared)).toBe(0) + expect(existsSync(join(secondary, 'personal.txt'))).toBe(false) + expect(readFileSync(join(secondary, 'resources', 'app.asar'), 'utf8')).toBe('packaged app') + + const restored = runHelper({ + action: 'Restore', source, target: source, journal, secondary, userProfile + }) + expect(restored.status, processError(restored)).toBe(0) + expect(readFileSync(join(secondary, 'personal.txt'), 'utf8')).toBe('keep external content') + expect(existsSync(journal)).toBe(false) + }) + + it('rejects a misleading external current-user source without packaged payload', () => { const root = makeTempRoot() const userProfile = join(root, 'profile') const source = join(root, 'Machine Kun') @@ -269,11 +596,61 @@ windowsOnly('Windows installer migration helper', () => { }) expect(result.status).not.toBe(0) - expect(result.stderr).toContain('outside the current user profile') + expect(result.stderr).toContain('not a recognized packaged Kun installation') expect(readFileSync(join(secondary, 'resources', 'keep.txt'), 'utf8')).toBe('keep') expect(existsSync(journal)).toBe(false) }) + it('rejects a verified-looking external secondary source reached through a reparse point', () => { + const root = makeTempRoot() + const userProfile = join(root, 'profile') + const source = join(root, 'Machine Kun') + const externalBacking = join(root, 'external-backing') + const secondary = join(root, 'external-link') + const journal = join(root, 'recovery', 'journal.json') + mkdirSync(userProfile, { recursive: true }) + mkdirSync(join(source, 'resources'), { recursive: true }) + mkdirSync(join(externalBacking, 'resources'), { recursive: true }) + writeFileSync(join(source, 'Kun.exe'), 'app') + writeFileSync(join(externalBacking, 'Kun.exe'), 'app') + writeFileSync(join(externalBacking, 'resources', 'app.asar'), 'packaged app') + symlinkSync(externalBacking, secondary, 'junction') + + const result = runHelper({ + action: 'Prepare', source, target: source, journal, secondary, userProfile + }) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('path contains a reparse point') + expect(readFileSync(join(externalBacking, 'resources', 'app.asar'), 'utf8')).toBe('packaged app') + expect(existsSync(journal)).toBe(false) + }) + + it('ignores a missing stale external current-user source during migration', () => { + const root = makeTempRoot() + const userProfile = join(root, 'profile') + const source = join(root, 'Machine Kun') + const secondary = join(root, 'missing-drive', 'Kun') + const journal = join(root, 'recovery', 'journal.json') + const resultPath = join(root, 'prepare-result.txt') + mkdirSync(userProfile, { recursive: true }) + mkdirSync(join(source, 'resources'), { recursive: true }) + writeFileSync(join(source, 'Kun.exe'), 'app') + + const prepared = runHelper({ + action: 'Prepare', source, target: source, journal, secondary, userProfile, resultPath + }) + expect(prepared.status, processError(prepared)).toBe(0) + expect(readFileSync(resultPath, 'utf16le')).toBe('2') + expect(existsSync(secondary)).toBe(false) + + const restored = runHelper({ + action: 'Restore', source, target: source, journal, secondary, userProfile + }) + expect(restored.status, processError(restored)).toBe(0) + expect(existsSync(journal)).toBe(false) + }) + it('rejects fallback cleanup when the source does not match its preservation journal', () => { const root = makeTempRoot() const source = join(root, 'DeepSeek GUI') diff --git a/src/renderer/src/components/chat/FloatingComposerQueuedMessages.tsx b/src/renderer/src/components/chat/FloatingComposerQueuedMessages.tsx index ead1db3cb..30a93f82a 100644 --- a/src/renderer/src/components/chat/FloatingComposerQueuedMessages.tsx +++ b/src/renderer/src/components/chat/FloatingComposerQueuedMessages.tsx @@ -18,6 +18,7 @@ import { } from 'lucide-react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' +import { queuedMessageGuidancePayload } from '../../store/queued-message-guidance' const QUEUED_MENU_WIDTH = 176 const QUEUED_MENU_HEIGHT = 48 @@ -90,18 +91,7 @@ export type QueuedComposerMessage = { /** True when the text-only steer contract can preserve the whole queued payload. */ export function canGuideQueuedComposerMessage(message: QueuedComposerMessage): boolean { - return Boolean( - message.text.trim() && - !message.attachmentIds?.length && - !message.attachments?.length && - !message.fileReferences?.length && - !message.composerContexts?.length && - !message.guiPlan && - message.guiDesignCanvas !== true && - message.guiDesignMode !== true && - !message.guiDesignArtifact && - !message.writeContext - ) + return queuedMessageGuidancePayload(message) !== null } /** Editing dequeues the item, so only payloads that can be fully restored as text are eligible. */ diff --git a/src/renderer/src/components/design/DesignAIRail.test.ts b/src/renderer/src/components/design/DesignAIRail.test.ts index 7d1483df4..5d22f1a28 100644 --- a/src/renderer/src/components/design/DesignAIRail.test.ts +++ b/src/renderer/src/components/design/DesignAIRail.test.ts @@ -113,7 +113,14 @@ describe('DesignAIRail target toggle', () => { const html = renderToStaticMarkup(createElement(DesignAIRail, props({ busy: true, activeThreadId: 'thread-current-document', - queuedMessages: [{ id: 'q-guide', text: 'use a smaller title' }], + queuedMessages: [{ + id: 'q-guide', + text: 'Expanded internal Design prompt with canvas state', + displayText: 'Use a smaller title', + guiDesignCanvas: true, + guiDesignMode: true, + agentSurface: 'design' + }], designThreads: [{ id: 'thread-current-document', title: 'Settings', @@ -124,7 +131,9 @@ describe('DesignAIRail target toggle', () => { }] }))) + expect(html).toContain('Use a smaller title') expect(html).toContain('aria-label="Guide"') + expect(html).toMatch(/]*aria-label="Guide")(?![^>]*disabled="")[^>]*>/) expect(html).toMatch(/]*disabled=""[^>]*aria-label="Clear history"/) }) diff --git a/src/renderer/src/components/design/DesignSidebar.test.ts b/src/renderer/src/components/design/DesignSidebar.test.ts index 500de7ed8..1284c4340 100644 --- a/src/renderer/src/components/design/DesignSidebar.test.ts +++ b/src/renderer/src/components/design/DesignSidebar.test.ts @@ -6,6 +6,7 @@ import { getDesignSidebarDocumentLabel, getDesignSidebarDocumentScreenCount, getDesignSidebarVisibleArtifacts, + resolveDesignSidebarNavigationLocks, sortDesignSidebarDocuments } from './DesignSidebar' @@ -116,4 +117,26 @@ describe('DesignSidebar helpers', () => { expect(sortDesignSidebarDocuments(documents, (document) => document.id === 'running').map((document) => document.id)) .toEqual(['running', 'recent', 'old']) }) + + it('allows switching modes while a Design agent keeps running in the background', () => { + expect(resolveDesignSidebarNavigationLocks({ + workspaceSwitching: false, + drawingCreationSubmitting: false, + designAgentRunning: true + })).toEqual({ + modeSwitchLocked: false, + designNavigationLocked: true + }) + }) + + it('keeps mode switching locked while a drawing submission is still being prepared', () => { + expect(resolveDesignSidebarNavigationLocks({ + workspaceSwitching: false, + drawingCreationSubmitting: true, + designAgentRunning: false + })).toEqual({ + modeSwitchLocked: true, + designNavigationLocked: true + }) + }) }) diff --git a/src/renderer/src/components/design/DesignSidebar.tsx b/src/renderer/src/components/design/DesignSidebar.tsx index a9e5a6010..1a26f1d84 100644 --- a/src/renderer/src/components/design/DesignSidebar.tsx +++ b/src/renderer/src/components/design/DesignSidebar.tsx @@ -69,6 +69,21 @@ export { sortDesignSidebarDocuments } from './design-sidebar-model' +export function resolveDesignSidebarNavigationLocks(options: { + workspaceSwitching: boolean + drawingCreationSubmitting: boolean + designAgentRunning: boolean +}): { + modeSwitchLocked: boolean + designNavigationLocked: boolean +} { + const preparationLocked = options.workspaceSwitching || options.drawingCreationSubmitting + return { + modeSwitchLocked: preparationLocked, + designNavigationLocked: preparationLocked || options.designAgentRunning + } +} + type Props = { onCodeOpen: () => void onWriteOpen: () => void @@ -281,10 +296,18 @@ export function DesignSidebar({ (threadId === chatActiveThreadId && chatBusy) }) } - const navigationLocked = workspaceSwitching || drawingCreationSubmitting || chatThreads.some((thread) => + const designAgentRunning = chatThreads.some((thread) => runningDesignThreadIds.has(thread.id) && (thread.status?.trim().toLowerCase() === 'running' || (thread.id === chatActiveThreadId && chatBusy)) ) + const { + modeSwitchLocked, + designNavigationLocked: navigationLocked + } = resolveDesignSidebarNavigationLocks({ + workspaceSwitching, + drawingCreationSubmitting, + designAgentRunning + }) const persistWorkspaceSelection = async (root: string, options?: { remove?: boolean }): Promise => { const settings = await rendererRuntimeClient.getSettings() @@ -575,7 +598,7 @@ export function DesignSidebar({ onCodeOpen={onCodeOpen} onWriteOpen={onWriteOpen} onDesignOpen={onDesignOpen} - disabled={navigationLocked} + disabled={modeSwitchLocked} disabledReason={t('designDrawingPreparing')} /> } describe('PrototypePlayerOverlay', () => { + it('fits a fixed prototype viewport without changing its intrinsic dimensions', () => { + expect(prototypeViewportFitScale(1280, 800, 1280, 800)).toBe(1) + expect(prototypeViewportFitScale(960, 900, 1280, 800)).toBe(0.75) + expect(prototypeViewportFitScale(1400, 600, 1280, 800)).toBe(0.75) + expect(prototypeViewportFitScale(0, 600, 1280, 800)).toBe(1) + }) + + it('opens the active HTML artifact through the authorized system-browser bridge', async () => { + const openPrototype = vi.fn(async () => ({ ok: true })) + + await expect(openPrototypeHtmlInBrowser( + openPrototype, + '/workspace', + '.kun-design/doc/home/v1.html' + )).resolves.toEqual({ ok: true }) + expect(openPrototype).toHaveBeenCalledWith({ + path: '.kun-design/doc/home/v1.html', + workspaceRoot: '/workspace' + }) + }) + it('waits for webview readiness before injecting navigation capture', () => { expect(shouldInjectPrototypeNavigationCapture({ open: true, @@ -119,11 +142,11 @@ describe('PrototypePlayerOverlay', () => { }) ) - expect(html).toContain('aspect-ratio:390 / 844') - expect(html).toContain('height:100%') + expect(html).toContain('width:390px;height:844px;transform:scale(1)') expect(html).toContain('.kun-design/doc/home/v1.html - App 390 x 844') expect(html).toContain('aria-label="Prototype viewport"') expect(html).toContain('aria-pressed="true"') + expect(html).toContain('aria-label="Open in browser"') expect(html).toContain('rounded-[30px]') expect(html).not.toContain('ring-[6px]') expect(html).toContain('All screens') @@ -145,10 +168,11 @@ describe('PrototypePlayerOverlay', () => { }) ) - expect(html).toContain('aspect-ratio:1280 / 800') - expect(html).toContain('width:100%') + expect(html).toContain('width:1280px;height:800px;transform:scale(1)') expect(html).toContain('.kun-design/doc/home/v1.html - Web 1280 x 800') expect(html).toContain('1280 x 800 web prototype') + expect(html).toContain('fixed inset-0') + expect(html).not.toContain('max-h-full max-w-full') }) it('renders the current version path instead of stale screen paths', () => { diff --git a/src/renderer/src/components/design/canvas/PrototypePlayerOverlay.tsx b/src/renderer/src/components/design/canvas/PrototypePlayerOverlay.tsx index ac10bdc66..2290700da 100644 --- a/src/renderer/src/components/design/canvas/PrototypePlayerOverlay.tsx +++ b/src/renderer/src/components/design/canvas/PrototypePlayerOverlay.tsx @@ -101,6 +101,48 @@ html[data-kun-prototype-viewport="app"] *::-webkit-scrollbar { `.trim() } +export function prototypeViewportFitScale( + availableWidth: number, + availableHeight: number, + viewportWidth: number, + viewportHeight: number +): number { + if ( + !Number.isFinite(availableWidth) || + !Number.isFinite(availableHeight) || + !Number.isFinite(viewportWidth) || + !Number.isFinite(viewportHeight) || + availableWidth <= 0 || + availableHeight <= 0 || + viewportWidth <= 0 || + viewportHeight <= 0 + ) { + return 1 + } + return Math.min(1, availableWidth / viewportWidth, availableHeight / viewportHeight) +} + +export async function openPrototypeHtmlInBrowser( + openPrototype: ((payload: { path: string; workspaceRoot: string }) => Promise<{ + ok: boolean + message?: string + }>) | undefined, + workspaceRoot: string, + relativePath: string +): Promise<{ ok: boolean; message?: string }> { + if (!openPrototype || !workspaceRoot.trim() || !relativePath.trim()) { + return { ok: false, message: 'Prototype browser opening is unavailable.' } + } + try { + return await openPrototype({ path: relativePath, workspaceRoot }) + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error) + } + } +} + function PrototypePlayerOverlayInner({ open, workspaceRoot, @@ -114,7 +156,10 @@ function PrototypePlayerOverlayInner({ const [currentId, setCurrentId] = useState(null) const [history, setHistory] = useState([]) const [missingHref, setMissingHref] = useState('') + const [browserOpenError, setBrowserOpenError] = useState('') const [previewTarget, setPreviewTarget] = useState(() => normalizeDesignTarget(designTarget)) + const [viewportScale, setViewportScale] = useState(1) + const viewportHostRef = useRef(null) const wasOpenRef = useRef(false) const lastInitialCurrentIdRef = useRef(null) @@ -144,12 +189,21 @@ function PrototypePlayerOverlayInner({ () => resolvePrototypeViewportFrame(currentArtifact, previewTarget), [currentArtifact, previewTarget] ) + const viewportVisualStyle = useMemo( + () => ({ + width: viewportFrame.width * viewportScale, + height: viewportFrame.height * viewportScale + }), + [viewportFrame.height, viewportFrame.width, viewportScale] + ) const viewportFrameStyle = useMemo( () => ({ - aspectRatio: `${viewportFrame.width} / ${viewportFrame.height}`, - ...(viewportFrame.orientation === 'portrait' ? { height: '100%' } : { width: '100%' }) + width: viewportFrame.width, + height: viewportFrame.height, + transform: `scale(${viewportScale})`, + transformOrigin: 'top left' }), - [viewportFrame] + [viewportFrame.height, viewportFrame.width, viewportScale] ) const webSize = defaultFrameSizeForDesignTarget('web') const appSize = defaultFrameSizeForDesignTarget('app') @@ -164,6 +218,29 @@ function PrototypePlayerOverlayInner({ partition: 'kun-proto' }) + useEffect(() => { + const host = viewportHostRef.current + if (!open || !host) return + const updateScale = (): void => { + const bounds = host.getBoundingClientRect() + const next = prototypeViewportFitScale( + bounds.width, + bounds.height, + viewportFrame.width, + viewportFrame.height + ) + setViewportScale((current) => Math.abs(current - next) < 0.001 ? current : next) + } + updateScale() + const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(updateScale) : null + observer?.observe(host) + window.addEventListener('resize', updateScale) + return () => { + observer?.disconnect() + window.removeEventListener('resize', updateScale) + } + }, [open, viewportFrame.height, viewportFrame.width]) + useEffect(() => { if (!open || wasOpenRef.current) return setPreviewTarget(normalizeDesignTarget(designTarget)) @@ -173,6 +250,7 @@ function PrototypePlayerOverlayInner({ if (!open) { wasOpenRef.current = false lastInitialCurrentIdRef.current = null + setBrowserOpenError('') return } if (!shouldInitializePrototypePlayerCurrentId({ open, wasOpen: wasOpenRef.current, currentId })) return @@ -309,11 +387,31 @@ function PrototypePlayerOverlayInner({ onClose() }, [currentArtifact, missingHref, onClose, onRequestMissingScreen, t]) + const openInBrowser = useCallback((): void => { + setBrowserOpenError('') + void openPrototypeHtmlInBrowser( + typeof window.kunGui?.openWritePrototype === 'function' + ? window.kunGui.openWritePrototype + : undefined, + workspaceRoot, + currentArtifactPath + ).then((result) => { + if (!result.ok) { + setBrowserOpenError(result.message || t('designPrototypeOpenBrowserFailed', 'Could not open the prototype in a browser.')) + } + }) + }, [currentArtifactPath, t, workspaceRoot]) + if (!open) return null return ( -
-
+
+
-
-
-
- {preview.state.webviewUrl ? ( - preview.renderWebview({ className: 'h-full w-full border-0' }) - ) : ( -
- {preview.state.error || t('designCanvasLoading')} -
- )} - {preview.state.error && preview.state.webviewUrl ? ( -
- {preview.state.error} -
- ) : null} +
+
+
+
+ {preview.state.webviewUrl ? ( + preview.renderWebview({ className: 'h-full w-full border-0' }) + ) : ( +
+ {preview.state.error || t('designCanvasLoading')} +
+ )} + {(browserOpenError || preview.state.error) && preview.state.webviewUrl ? ( +
+ {browserOpenError || preview.state.error} +
+ ) : null} +
diff --git a/src/renderer/src/components/settings-section-model-routes.test.ts b/src/renderer/src/components/settings-section-model-routes.test.ts index 525390471..84a51ad13 100644 --- a/src/renderer/src/components/settings-section-model-routes.test.ts +++ b/src/renderer/src/components/settings-section-model-routes.test.ts @@ -1,13 +1,14 @@ import { createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { act, create as createRenderer, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { defaultModelProviderSettings, getModelProviderPreset, modelProviderPresetAccountProfile, type ModelProviderSettingsV1 } from '@shared/app-settings' +import i18n from '../i18n' import { ModelRoutesSettings } from './settings-section-model-routes' function settings(): ModelProviderSettingsV1 { @@ -17,13 +18,13 @@ function settings(): ModelProviderSettingsV1 { localGateway: { enabled: true, name: 'Kun API' }, routePools: [ { - id: 'kimi-pool', name: 'Kimi 容量池', modelId: 'kimi-auto', enabled: true, strategy: 'adaptive', + id: 'kimi-pool', name: 'Kimi pool', modelId: 'kimi-auto', enabled: true, strategy: 'adaptive', targets: [{ id: 'target', providerId: defaults.providers[0].id, modelId: defaults.providers[0].models[0], enabled: true, weight: 2 }], failurePolicy: { failoverHttpStatusCodes: [429, 503], failoverOnNetworkError: true, failoverOnTimeout: true, failoverOnAuthError: true }, healthPolicy: { failureThreshold: 3, cooldownMs: 60_000, halfOpenMaxAttempts: 1 } }, { - id: 'code-pool', name: 'Coding 容量池', modelId: 'code-auto', enabled: true, strategy: 'priority', + id: 'code-pool', name: 'Coding pool', modelId: 'code-auto', enabled: true, strategy: 'priority', targets: [{ id: 'code-target', providerId: defaults.providers[0].id, modelId: defaults.providers[0].models[0], enabled: true, weight: 1 }], failurePolicy: { failoverHttpStatusCodes: [429, 503], failoverOnNetworkError: true, failoverOnTimeout: true, failoverOnAuthError: true }, healthPolicy: { failureThreshold: 3, cooldownMs: 60_000, halfOpenMaxAttempts: 1 } @@ -33,9 +34,14 @@ function settings(): ModelProviderSettingsV1 { } describe('ModelRoutesSettings', () => { - afterEach(() => { + beforeEach(async () => { + await i18n.changeLanguage('en') + }) + + afterEach(async () => { vi.useRealTimers() vi.unstubAllGlobals() + await i18n.changeLanguage('en') }) it('renders four persistent task tabs with matching panels', () => { @@ -97,24 +103,48 @@ describe('ModelRoutesSettings', () => { await act(async () => renderer.unmount()) }) - it('renders one local provider with multiple routed models and safety policy', () => { + it('renders the complete local provider workspace in English', () => { const html = renderToStaticMarkup(createElement(ModelRoutesSettings, { settings: settings(), onChange: () => undefined })) - expect(html).toContain('本地中转供应商') + expect(html).toContain('Local relay provider') expect(html).toContain('Kun API') - expect(html).toContain('2 / 2 个模型已启用') - expect(html).toContain('路由模型') - expect(html).toContain('Kimi 容量池') + expect(html).toContain('2 / 2 models enabled') + expect(html).toContain('Routed models') + expect(html).toContain('Kimi pool') expect(html).toContain('kimi-auto') - expect(html).toContain('Coding 容量池') + expect(html).toContain('Coding pool') expect(html).toContain('code-auto') - expect(html).toContain('添加模型') - expect(html).toContain('稳定性优先自适应') - expect(html).toContain('流式输出开始后固定停止') - expect(html).toContain('仅本机访问 · 无鉴权') + expect(html).toContain('Add model') + expect(html).toContain('Stability-first adaptive') + expect(html).toContain('After streaming output begins') + expect(html).toContain('Local access only · No authentication') expect(html).toContain('http://127.0.0.1:18899/v1') expect(html).toContain('GET /models') expect(html).toContain('POST /chat/completions') expect(html).toContain('POST /responses') + expect(html).not.toMatch(/[\p{Script=Han}]/u) + }) + + it('reacts to a Simplified Chinese locale change without remounting', async () => { + let renderer!: ReactTestRenderer + await act(async () => { + renderer = createRenderer(createElement(ModelRoutesSettings, { + settings: settings(), + onChange: () => undefined, + active: false + })) + }) + expect(textContent(renderer.root)).toContain('Local relay provider') + + await act(async () => { + await i18n.changeLanguage('zh') + }) + + const localized = textContent(renderer.root) + expect(localized).toContain('本地中转供应商') + expect(localized).toContain('2 / 2 个模型已启用') + expect(localized).toContain('稳定性优先自适应') + expect(localized).not.toContain('Local relay provider') + await act(async () => { renderer.unmount() }) }) it('opens a detailed local API dialog with endpoint examples', async () => { @@ -133,16 +163,18 @@ describe('ModelRoutesSettings', () => { }) await act(async () => { - const button = renderer!.root.findAllByType('button').find((item) => item.children.join('')?.includes('接口说明')) + const button = renderer!.root.findAllByType('button').find((item) => item.children.join('')?.includes('API guide')) button!.props.onClick() }) expect(renderer!.root.findByProps({ role: 'dialog' })).toBeDefined() const dialogTree = JSON.stringify(renderer!.toJSON()) - expect(dialogTree).toContain('本地 API 说明') + expect(dialogTree).toContain('Local API guide') expect(dialogTree).toContain('Chat Completions') expect(dialogTree).toContain('http://127.0.0.1:19999/v1') expect(dialogTree).toContain('/chat/completions') + expect(dialogTree).toContain('Hello, Kun!') + expect(dialogTree).not.toMatch(/[\p{Script=Han}]/u) await act(async () => { renderer!.unmount() }) }) @@ -181,14 +213,14 @@ describe('ModelRoutesSettings', () => { }) await act(async () => { - renderer!.root.findByProps({ 'aria-label': '开放本地 API' }).props.onClick() + renderer!.root.findByProps({ 'aria-label': 'Enable local API' }).props.onClick() }) expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ localGateway: expect.objectContaining({ enabled: true }) })) await act(async () => { - renderer!.root.findByProps({ 'aria-label': '启用路由池' }).props.onClick() + renderer!.root.findByProps({ 'aria-label': 'Enable route pool' }).props.onClick() }) expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ routePools: expect.arrayContaining([expect.objectContaining({ id: 'kimi-pool', enabled: true })]) @@ -217,10 +249,10 @@ describe('ModelRoutesSettings', () => { }) const content = textContent(renderer!.root) - expect(content).toContain('本地保存失败') - expect(content).toContain('Kun Runtime 未连接') + expect(content).toContain('Local save failed') + expect(content).toContain('Kun Runtime not connected') expect(content).toContain('disk write failed') - const retry = renderer!.root.findAllByType('button').find((button) => textContent(button).includes('重试保存')) + const retry = renderer!.root.findAllByType('button').find((button) => textContent(button).includes('Retry save')) await act(async () => { retry!.props.onClick() }) expect(onRetrySave).toHaveBeenCalledOnce() @@ -243,11 +275,11 @@ describe('ModelRoutesSettings', () => { }) const content = textContent(renderer!.root) - expect(content).toContain('供应商已删除:removed-provider') - expect(content).toContain('原模型:removed-model') - expect(content).toContain('引用已保留') - expect(content).toContain('修复无效目标后测试') - expect(content).toContain('没有可执行目标') + expect(content).toContain('Provider deleted: removed-provider') + expect(content).toContain('Original model: removed-model') + expect(content).toContain('The reference was preserved') + expect(content).toContain('Fix invalid targets to test') + expect(content).toContain('no executable target') await act(async () => { renderer!.unmount() }) }) @@ -268,7 +300,7 @@ describe('ModelRoutesSettings', () => { await act(async () => { renderer = createRenderer(createElement(ModelRoutesSettings, { settings: draft, onChange: () => undefined })) }) - const testButton = renderer!.root.findAllByType('button').find((button) => textContent(button).includes('测试完整链路')) + const testButton = renderer!.root.findAllByType('button').find((button) => textContent(button).includes('Test complete route')) expect(testButton).toBeDefined() await act(async () => { @@ -278,9 +310,9 @@ describe('ModelRoutesSettings', () => { }) expect(runtimeRequest).toHaveBeenCalledWith('/v1/model-routes/kimi-pool/test', 'POST') - expect(textContent(renderer!.root)).toContain('测试进行中') - expect(textContent(renderer!.root)).toContain('已尝试 2 / 2 个目标') - expect(textContent(renderer!.root)).toContain('正在测试:provider-backup / kimi-backup') + expect(textContent(renderer!.root)).toContain('Test in progress') + expect(textContent(renderer!.root)).toContain('Attempted 2 / 2 targets') + expect(textContent(renderer!.root)).toContain('Testing: provider-backup / kimi-backup') await act(async () => { renderer!.unmount() }) }) @@ -295,8 +327,8 @@ describe('ModelRoutesSettings', () => { await act(async () => { renderer = createRenderer(createElement(ModelRoutesSettings, { settings: draft, onChange: () => undefined })) }) - expect(textContent(renderer!.root)).toContain('测试进行中') - expect(textContent(renderer!.root)).toContain('正在测试:provider-backup / kimi-backup') + expect(textContent(renderer!.root)).toContain('Test in progress') + expect(textContent(renderer!.root)).toContain('Testing: provider-backup / kimi-backup') await act(async () => { renderer!.unmount() }) statusBody = routeStatus(draft, [testRecord('succeeded')]) @@ -304,10 +336,10 @@ describe('ModelRoutesSettings', () => { renderer = createRenderer(createElement(ModelRoutesSettings, { settings: draft, onChange: () => undefined })) }) const restored = textContent(renderer!.root) - expect(restored).toContain('链路测试成功') - expect(restored).toContain('最终目标:provider-backup / kimi-backup') - expect(restored).toContain('模型响应:OK') - expect(restored).toContain('最近测试记录') + expect(restored).toContain('Route test succeeded') + expect(restored).toContain('Final target: provider-backup / kimi-backup') + expect(restored).toContain('Model response: OK') + expect(restored).toContain('Recent test records') await act(async () => { renderer!.unmount() }) }) @@ -321,11 +353,11 @@ describe('ModelRoutesSettings', () => { renderer = createRenderer(createElement(ModelRoutesSettings, { settings: draft, onChange: () => undefined })) }) - const testButton = renderer!.root.findAllByType('button').find((button) => textContent(button).includes('等待配置同步')) + const testButton = renderer!.root.findAllByType('button').find((button) => textContent(button).includes('Waiting for configuration sync')) expect(testButton?.props.disabled).toBe(true) - expect(textContent(renderer!.root)).toContain('本地配置已保存,正在等待 Kun Runtime') - expect(textContent(renderer!.root)).toContain('等待 Kun Runtime 同步') - expect(textContent(renderer!.root)).not.toContain('Kun Runtime 同步失败') + expect(textContent(renderer!.root)).toContain('Local configuration was saved and is waiting for Kun Runtime') + expect(textContent(renderer!.root)).toContain('Waiting for Kun Runtime sync') + expect(textContent(renderer!.root)).not.toContain('Kun Runtime sync failed') expect(runtimeRequest.mock.calls.some((call) => call[1] === 'POST')).toBe(false) await act(async () => { renderer!.unmount() }) @@ -361,9 +393,9 @@ describe('ModelRoutesSettings', () => { }) const content = textContent(renderer!.root) - expect(content).toContain('Kun Runtime 同步失败') + expect(content).toContain('Kun Runtime sync failed') expect(content).toContain('hot apply rejected the route config') - expect(content).toContain('本地配置已保存,但 Kun Runtime 同步失败') + expect(content).toContain('Local configuration was saved, but Kun Runtime sync failed') await act(async () => { renderer!.unmount() }) }) @@ -421,7 +453,7 @@ describe('ModelRoutesSettings', () => { await Promise.resolve() }) - expect(textContent(renderer!.root)).toContain('Kun Runtime 同步失败') + expect(textContent(renderer!.root)).toContain('Kun Runtime sync failed') expect(textContent(renderer!.root)).toContain('latest apply failed') await act(async () => { renderer!.unmount() }) diff --git a/src/renderer/src/components/settings-section-model-routes.tsx b/src/renderer/src/components/settings-section-model-routes.tsx index 348652c40..f949818d7 100644 --- a/src/renderer/src/components/settings-section-model-routes.tsx +++ b/src/renderer/src/components/settings-section-model-routes.tsx @@ -1,4 +1,6 @@ import { useCallback, useEffect, useMemo, useState, type DragEvent, type ReactElement } from 'react' +import type { TFunction } from 'i18next' +import { useTranslation } from 'react-i18next' import type { ModelProviderSettingsV1, ModelRoutePoolV1, ModelRouteStrategy } from '@shared/app-settings' import type { KunRuntimeSettingsSyncStatusPayload } from '@shared/kun-gui-api' import { @@ -52,28 +54,28 @@ type RoutePoolTestRecord = { type RouteTestTarget = { targetId: string; providerId: string; modelId: string } type ModelRouteSettingsTab = 'gateway' | 'models' | 'resilience' | 'monitoring' -const strategies: Array<{ id: ModelRouteStrategy; label: string }> = [ - { id: 'priority', label: '优先级故障转移' }, - { id: 'round-robin', label: '轮询' }, - { id: 'weighted-round-robin', label: '加权轮询' }, - { id: 'least-latency', label: '最低延迟' }, - { id: 'adaptive', label: '稳定性优先自适应' } -] +const strategyTranslationKeys: Record = { + priority: 'modelRoutes.strategyPriority', + 'round-robin': 'modelRoutes.strategyRoundRobin', + 'weighted-round-robin': 'modelRoutes.strategyWeightedRoundRobin', + 'least-latency': 'modelRoutes.strategyLeastLatency', + adaptive: 'modelRoutes.strategyAdaptive' +} -function EmptyRoutePoolState({ onAdd }: { onAdd: () => void }): ReactElement { +function EmptyRoutePoolState({ onAdd, t }: { onAdd: () => void; t: TFunction }): ReactElement { return (
-

添加第一个路由模型

-

一个本地中转供应商可以包含多个公开模型。

+

{t('modelRoutes.emptyTitle')}

+

{t('modelRoutes.gatewayMultipleModelsDesc')}

@@ -98,6 +100,7 @@ export function ModelRoutesSettings({ /** The configured local Kun endpoint; this is also the public gateway origin. */ publicBaseUrl?: string }): ReactElement { + const { t, i18n } = useTranslation('settings') const [selectedId, setSelectedId] = useState(settings.routePools[0]?.id ?? '') const [status, setStatus] = useState(null) const [statusError, setStatusError] = useState('') @@ -121,7 +124,7 @@ export function ModelRoutesSettings({ const refreshStatus = useCallback(async (): Promise => { try { const response = await window.kunGui.runtimeRequest(KUN_MODEL_ROUTES_PATH, 'GET') - if (!response.ok) throw new Error(routeStatusError(response.body, response.status)) + if (!response.ok) throw new Error(routeStatusError(response.body, response.status, t)) setStatus(JSON.parse(response.body) as RouteStatus) setStatusError('') } catch (error) { @@ -129,7 +132,7 @@ export function ModelRoutesSettings({ setStatus(null) setStatusError(error instanceof Error ? error.message : String(error)) } - }, []) + }, [t]) useEffect(() => { if (!active) return void refreshStatus() @@ -181,7 +184,7 @@ export function ModelRoutesSettings({ ])) const pool: ModelRoutePoolV1 = { id, - name: `路由模型 ${ordinal}`, + name: t('modelRoutes.defaultRouteName', { index: ordinal }), modelId, enabled: false, strategy: 'priority', @@ -207,7 +210,7 @@ export function ModelRoutesSettings({ try { const response = await window.kunGui.runtimeRequest(kunModelRouteTestPath(selected.id), 'POST') const body = JSON.parse(response.body) as { test?: RoutePoolTestRecord; error?: { message?: string } } - if (!response.ok || !body.test) throw new Error(body.error?.message ?? '无法创建链路测试') + if (!response.ok || !body.test) throw new Error(body.error?.message ?? t('modelRoutes.testCreateFailed')) setStatus((current) => ({ ...(current ?? {}), tests: [body.test!, ...(current?.tests ?? []).filter((test) => test.id !== body.test!.id)] @@ -237,44 +240,48 @@ export function ModelRoutesSettings({ const invalidTargetCount = selected?.targets.filter((target) => resolveModelRouteTargetReference(target, settings.providers).status !== 'valid' ).length ?? 0 + const strategies = (Object.keys(strategyTranslationKeys) as ModelRouteStrategy[]).map((id) => ({ + id, + label: t(strategyTranslationKeys[id]) + })) const testButtonLabel = startPending - ? '正在创建测试' + ? t('modelRoutes.testButtonCreating') : activeTest - ? '测试进行中' + ? t('modelRoutes.testButtonInProgress') : saveStatus === 'error' - ? '先修复保存失败' + ? t('modelRoutes.testButtonFixSave') : saveStatus === 'saving' - ? '等待本地保存' + ? t('modelRoutes.testButtonWaitSave') : !selected?.enabled - ? '启用后可测试' + ? t('modelRoutes.testButtonEnableFirst') : !selectedHasExecutableTarget - ? '修复无效目标后测试' + ? t('modelRoutes.testButtonFixInvalidTargets') : !status - ? 'Kun Runtime 不可用' + ? t('modelRoutes.runtimeUnavailable') : !runtimeReady - ? '等待配置同步' - : '测试完整链路' + ? t('modelRoutes.testButtonWaitSync') + : t('modelRoutes.testButtonRun') const localSaveLabel = saveStatus === 'saving' - ? '正在保存到本地' + ? t('modelRoutes.localSaveSaving') : saveStatus === 'error' - ? '本地保存失败' - : '已保存到本地' + ? t('modelRoutes.localSaveFailed') + : t('modelRoutes.localSaveComplete') const runtimeSyncFailed = Boolean( !configurationSynced && runtimeSyncStatus?.state === 'failed' ) const runtimeSyncLabel = configurationSynced - ? 'Kun Runtime 已同步' + ? t('modelRoutes.runtimeSynced') : runtimeSyncFailed - ? 'Kun Runtime 同步失败' + ? t('modelRoutes.runtimeSyncFailed') : !status - ? runtimeSyncStatus?.state === 'unavailable' ? 'Kun Runtime 未运行' : 'Kun Runtime 未连接' + ? runtimeSyncStatus?.state === 'unavailable' ? t('modelRoutes.runtimeNotRunning') : t('modelRoutes.runtimeNotConnected') : runtimeSyncStatus?.state === 'syncing' - ? '正在同步到 Kun Runtime' - : '等待 Kun Runtime 同步' + ? t('modelRoutes.runtimeSyncing') + : t('modelRoutes.runtimeWaitingForSync') const gatewayBaseUrl = `${publicBaseUrl.replace(/\/$/, '')}/v1` const sampleModelId = selected?.modelId || settings.routePools.find((pool) => pool.enabled)?.modelId || 'your-public-model-id' - const curlExample = buildGatewayCurlExample(gatewayBaseUrl, sampleModelId) + const curlExample = buildGatewayCurlExample(gatewayBaseUrl, sampleModelId, t) const copyGatewayText = async (value: string, kind: 'base-url' | 'curl' | 'api-example'): Promise => { try { await navigator.clipboard.writeText(value) @@ -290,12 +297,12 @@ export function ModelRoutesSettings({
baseId="model-routes-settings" - ariaLabel="模型路由设置" + ariaLabel={t('modelRoutes.tabsAria')} items={[ - { id: 'gateway', label: '网关与 API', icon: Server }, - { id: 'models', label: '模型与目标', icon: Boxes }, - { id: 'resilience', label: '容错策略', icon: AlertTriangle }, - { id: 'monitoring', label: '验证与监控', icon: Activity } + { id: 'gateway', label: t('modelRoutes.tabGateway'), icon: Server }, + { id: 'models', label: t('modelRoutes.tabModels'), icon: Boxes }, + { id: 'resilience', label: t('modelRoutes.tabResilience'), icon: AlertTriangle }, + { id: 'monitoring', label: t('modelRoutes.tabMonitoring'), icon: Activity } ]} value={activeSettingsTab} onChange={setActiveSettingsTab} @@ -314,9 +321,12 @@ export function ModelRoutesSettings({
- 本地中转供应商 + {t('modelRoutes.localRelayProvider')} - {settings.routePools.filter((pool) => pool.enabled).length} / {settings.routePools.length} 个模型已启用 + {t('modelRoutes.enabledModelCount', { + enabled: settings.routePools.filter((pool) => pool.enabled).length, + total: settings.routePools.length + })}
-

一个供应商统一承载多个公开模型,每个模型可配置独立的路由目标和负载策略。

+

{t('modelRoutes.providerDesc')}

-
开放本地 API
-
仅本机访问 · 无鉴权
+
{t('modelRoutes.enableLocalApi')}
+
{t('modelRoutes.localOnlyNoAuth')}
@@ -363,29 +373,29 @@ export function ModelRoutesSettings({ }`}>{runtimeSyncLabel} {saveStatus === 'error' && onRetrySave ? ( ) : null} {saveStatus === 'error' && saveError ? {saveError} : null} - {!status && statusError ? 本地配置不受影响;Kun 启动后会自动同步。 : null} + {!status && statusError ? {t('modelRoutes.runtimeUnavailableHint')} : null} {runtimeSyncFailed && runtimeSyncStatus?.message ? {runtimeSyncStatus.message} : null}
-

本地 API

+

{t('modelRoutes.localApi')}

- {settings.localGateway.enabled ? '已启用 · 仅本机访问' : '未启用'} + {settings.localGateway.enabled ? t('modelRoutes.localApiEnabledLocalOnly') : t('modelRoutes.disabled')}
-

兼容 OpenAI Chat Completions 与 Responses;公开模型 ID 由下方路由模型决定。

+

{t('modelRoutes.apiCompatibilityDesc')}

{gatewayBaseUrl} -
@@ -397,10 +407,10 @@ export function ModelRoutesSettings({
@@ -417,12 +427,12 @@ export function ModelRoutesSettings({
@@ -455,24 +465,24 @@ export function ModelRoutesSettings({ <>
-

{settings.localGateway.name} / 路由模型

- updatePool({ name: event.target.value })} className="w-full bg-transparent text-[20px] font-semibold text-ds-ink outline-none" /> -

配置保存后会热更新到当前 Kun Runtime。

+

{settings.localGateway.name} / {t('modelRoutes.routedModel')}

+ updatePool({ name: event.target.value })} className="w-full bg-transparent text-[20px] font-semibold text-ds-ink outline-none" /> +

{t('modelRoutes.hotUpdateHint')}

-
启用 updatePool({ enabled })} ariaLabel="启用路由池" />
+
{t('modelRoutes.enable')} updatePool({ enabled })} ariaLabel={t('modelRoutes.enablePoolAria')} />
- updatePool({ modelId: event.target.value })} className={inputClass} spellCheck={false} /> - + updatePool({ modelId: event.target.value })} className={inputClass} spellCheck={false} /> +
-

路由目标

+ }} className="inline-flex items-center gap-1 rounded-full border border-ds-border px-3 py-1.5 text-[12px] text-ds-muted"> {t('modelRoutes.addTarget')}
{selected.targets.map((target, index) => { const resolution = resolveModelRouteTargetReference(target, settings.providers) @@ -486,22 +496,22 @@ export function ModelRoutesSettings({ const nextProvider = settings.providers.find((candidate) => candidate.id === event.target.value) updatePool({ targets: selected.targets.map((item) => item.id === target.id ? { ...item, providerId: event.target.value, modelId: nextProvider?.models[0] ?? '' } : item) }) }} className={compactInputClass}> - {resolution.status === 'provider-missing' ? : null} + {resolution.status === 'provider-missing' ? : null} {settings.providers.map((item) => )} - updatePool({ targets: selected.targets.map((item) => item.id === target.id ? { ...item, weight: Number(event.target.value) || 1 } : item) })} className={compactInputClass} /> -
{metric?.ewmaLatencyMs ? `${Math.round(metric.ewmaLatencyMs)} ms` : '未探测'}
{metric ? `${metric.successes}/${metric.successes + metric.failures} 成功` : ''}
+ updatePool({ targets: selected.targets.map((item) => item.id === target.id ? { ...item, weight: Number(event.target.value) || 1 } : item) })} className={compactInputClass} /> +
{metric?.ewmaLatencyMs ? `${Math.round(metric.ewmaLatencyMs)} ms` : t('modelRoutes.notProbed')}
{metric ? t('modelRoutes.successCount', { successes: metric.successes, total: metric.successes + metric.failures }) : ''}
{resolution.status !== 'valid' ? (
{resolution.status === 'provider-missing' - ? `供应商 ${target.providerId} 已不存在;引用已保留,请选择替代供应商或删除目标。` - : `模型 ${target.modelId} 已不在 ${target.providerId} 中;引用已保留,请选择替代模型或删除目标。`} + ? t('modelRoutes.providerMissingWarning', { providerId: target.providerId }) + : t('modelRoutes.modelMissingWarning', { modelId: target.modelId, providerId: target.providerId })}
) : null}
@@ -513,12 +523,12 @@ export function ModelRoutesSettings({
) : ( - + )} @@ -530,11 +540,27 @@ export function ModelRoutesSettings({ > {selected ? (
-

故障转移规则

updatePool({ failurePolicy: { ...selected.failurePolicy, failoverOnNetworkError: value } })} /> updatePool({ failurePolicy: { ...selected.failurePolicy, failoverOnTimeout: value } })} /> updatePool({ failurePolicy: { ...selected.failurePolicy, failoverOnAuthError: value } })} /> updatePool({ failurePolicy: { ...selected.failurePolicy, failoverHttpStatusCodes: parseCodes(event.target.value) } })} className={compactInputClass} />

流式输出开始后固定停止,不会重复请求或执行工具。

-

健康与熔断

updatePool({ healthPolicy: { ...selected.healthPolicy, failureThreshold: Number(event.target.value) } })} className={compactInputClass} /> updatePool({ healthPolicy: { ...selected.healthPolicy, cooldownMs: Number(event.target.value) * 1000 } })} className={compactInputClass} /> updatePool({ healthPolicy: { ...selected.healthPolicy, halfOpenMaxAttempts: Number(event.target.value) } })} className={compactInputClass} />
+
+

{t('modelRoutes.failoverRules')}

+
+ updatePool({ failurePolicy: { ...selected.failurePolicy, failoverOnNetworkError: value } })} /> + updatePool({ failurePolicy: { ...selected.failurePolicy, failoverOnTimeout: value } })} /> + updatePool({ failurePolicy: { ...selected.failurePolicy, failoverOnAuthError: value } })} /> + updatePool({ failurePolicy: { ...selected.failurePolicy, failoverHttpStatusCodes: parseCodes(event.target.value) } })} className={compactInputClass} /> +

{t('modelRoutes.afterStreamNoRetry')}

+
+
+
+

{t('modelRoutes.healthCircuit')}

+
+ updatePool({ healthPolicy: { ...selected.healthPolicy, failureThreshold: Number(event.target.value) } })} className={compactInputClass} /> + updatePool({ healthPolicy: { ...selected.healthPolicy, cooldownMs: Number(event.target.value) * 1000 } })} className={compactInputClass} /> + updatePool({ healthPolicy: { ...selected.healthPolicy, halfOpenMaxAttempts: Number(event.target.value) } })} className={compactInputClass} /> +
+
) : ( - + )} @@ -548,8 +574,8 @@ export function ModelRoutesSettings({
-

路由验证

-

测试由 Kun Runtime 异步执行,离开页面后仍会继续,返回时自动恢复进度和结果。

+

{t('modelRoutes.routeValidation')}

+

{t('modelRoutes.routeValidationDesc')}

: null} {selected.enabled && !runtimeReady ? (
- {chainTestBlockedReason({ saveStatus, status, statusError, runtimeSyncStatus, configurationSynced, selectedHasExecutableTarget, invalidTargetCount })} + {chainTestBlockedReason({ saveStatus, status, statusError, runtimeSyncStatus, configurationSynced, selectedHasExecutableTarget, invalidTargetCount }, t)}
) : null} @@ -574,31 +600,31 @@ export function ModelRoutesSettings({
{activeTest ? : } - {testStatusLabel(latestTest.status)} - {new Date(latestTest.createdAt).toLocaleString()} + {testStatusLabel(latestTest.status, t)} + {new Date(latestTest.createdAt).toLocaleString(i18n.resolvedLanguage)}
- 已尝试 {latestTest.attemptedTargets} / {latestTest.totalTargets} 个目标 + {t('modelRoutes.attemptedTargets', { attempted: latestTest.attemptedTargets, total: latestTest.totalTargets })}
- {latestTest.currentTarget ?

正在测试:{formatTarget(latestTest.currentTarget)}

: null} - {latestTest.selectedTarget ?

最终目标:{formatTarget(latestTest.selectedTarget)}

: null} - {latestTest.output ?
模型响应:{latestTest.output}
: null} + {latestTest.currentTarget ?

{t('modelRoutes.testingTarget', { target: formatTarget(latestTest.currentTarget) })}

: null} + {latestTest.selectedTarget ?

{t('modelRoutes.finalTargetValue', { target: formatTarget(latestTest.selectedTarget) })}

: null} + {latestTest.output ?
{t('modelRoutes.modelResponse', { response: latestTest.output })}
: null} {latestTest.error ?
{latestTest.error.message}{latestTest.error.category ? ` · ${latestTest.error.category}` : ''}
: null}
- ) : status ?
暂无链路测试记录
: null} + ) : status ?
{t('modelRoutes.noTests')}
: null} {latestTest?.attempts.length ? (
-
本次目标进度
+
{t('modelRoutes.currentTargetProgress')}
- + {latestTest.attempts.map((attempt) => ( - + ))} @@ -608,13 +634,13 @@ export function ModelRoutesSettings({ {selectedTests.length ? (
-
最近测试记录
+
{t('modelRoutes.recentTests')}
顺序目标状态延迟 / 错误
{t('modelRoutes.order')}{t('modelRoutes.target')}{t('modelRoutes.status')}{t('modelRoutes.latencyError')}
{attempt.index} {attempt.providerId} / {attempt.modelId}{attemptStatusLabel(attempt.status)}{attemptStatusLabel(attempt.status, t)} {attempt.latencyMs === undefined ? '—' : `${attempt.latencyMs} ms`}{attempt.category ? ` · ${attempt.category}` : ''}{attempt.message ? ` · ${attempt.message}` : ''}
- + {selectedTests.slice(0, 5).map((test) => ( - - + + @@ -624,16 +650,16 @@ export function ModelRoutesSettings({ ) : null}
-
最近路由事件
+
{t('modelRoutes.recentEvents')}
时间结果尝试最终目标
{t('modelRoutes.time')}{t('modelRoutes.result')}{t('modelRoutes.attempts')}{t('modelRoutes.finalTarget')}
{new Date(test.createdAt).toLocaleString()}{testStatusLabel(test.status)}{new Date(test.createdAt).toLocaleString(i18n.resolvedLanguage)}{testStatusLabel(test.status, t)} {test.attemptedTargets} / {test.totalTargets} {test.selectedTarget ? formatTarget(test.selectedTarget) : '—'}
- - {events.map((event) => )} + + {events.map((event) => )}
时间目标结果延迟
{new Date(event.at).toLocaleTimeString()}{event.providerId} / {event.modelId}{event.result}{event.category ? ` · ${event.category}` : ''}{event.latencyMs} ms
{t('modelRoutes.time')}{t('modelRoutes.target')}{t('modelRoutes.result')}{t('modelRoutes.latency')}
{new Date(event.at).toLocaleTimeString(i18n.resolvedLanguage)}{event.providerId} / {event.modelId}{event.result}{event.category ? ` · ${event.category}` : ''}{event.latencyMs} ms
- {events.length === 0 ?
暂无路由事件
: null} + {events.length === 0 ?
{t('modelRoutes.noEvents')}
: null}
) : ( - + )}
@@ -660,6 +686,7 @@ function LocalGatewayApiDialog({ onClose: () => void onCopy: (value: string) => void }): ReactElement { + const { t } = useTranslation('settings') const [tab, setTab] = useState('chat') useEffect(() => { if (typeof globalThis.addEventListener !== 'function') return @@ -670,7 +697,7 @@ function LocalGatewayApiDialog({ return () => globalThis.removeEventListener('keydown', closeOnEscape) }, [onClose]) - const guide = gatewayApiGuide(tab, baseUrl, modelId) + const guide = gatewayApiGuide(tab, baseUrl, modelId, t) return (
{ if (event.target === event.currentTarget) onClose() @@ -678,23 +705,23 @@ function LocalGatewayApiDialog({
-

本地 API 说明

-

OpenAI 兼容接口,仅允许本机进程访问;不需要 Authorization 请求头。

+

{t('modelRoutes.apiDialogTitle')}

+

{t('modelRoutes.apiDialogDesc')}

- +
@@ -704,25 +731,25 @@ function LocalGatewayApiDialog({
{guide.method}

{guide.path}

{guide.description}

- OpenAI 兼容 + {t('modelRoutes.openAiCompatible')}
-
Base URL
+
{t('modelRoutes.baseUrlLabel')}
{baseUrl}
- - + +
-
cURL 示例
+
{t('modelRoutes.curlExample')}
{guide.example}
-

此中转站不是面向公网的 API 服务:它仅绑定本机回环地址,且目前无鉴权。不要通过端口映射或未受保护的反向代理暴露给局域网或公网。

+

{t('modelRoutes.apiSecurityWarning')}

@@ -750,7 +777,7 @@ function InfoList({ title, items }: { title: string; items: string[] }): ReactEl return

{title}

    {items.map((item) =>
  • {item}
  • )}
} -function gatewayApiGuide(tab: GatewayApiTab, baseUrl: string, modelId: string): { +function gatewayApiGuide(tab: GatewayApiTab, baseUrl: string, modelId: string, t: TFunction): { method: 'GET' | 'POST' path: string description: string @@ -761,47 +788,61 @@ function gatewayApiGuide(tab: GatewayApiTab, baseUrl: string, modelId: string): if (tab === 'models') return { method: 'GET', path: '/models', - description: '列出本地中转站中所有已启用的路由模型。返回的 data[].id 就是后续生成请求使用的公开模型 ID。', - fields: ['无需请求体。', '只返回已启用的路由模型;未启用或无效的容量池不会出现。'], - notes: ['成功时返回 OpenAI 风格的 object: "list" 与 data 数组。', '本地 API 未启用时返回 404(gateway_disabled)。'], + description: t('modelRoutes.guideModelsDesc'), + fields: [t('modelRoutes.guideModelsNoBody'), t('modelRoutes.guideModelsEnabledOnly')], + notes: [t('modelRoutes.guideModelsResponse'), t('modelRoutes.guideModelsDisabled')], example: `curl --request GET ${baseUrl}/models` } if (tab === 'responses') return { method: 'POST', path: '/responses', - description: '以 OpenAI Responses 风格生成内容,适合使用 input 作为单次输入的客户端。', - fields: [`model:必填,使用公开模型 ID,例如 ${modelId}。`, 'input:必填,可传字符串或消息数组。', 'stream:可选;true 时返回 Server-Sent Events。', 'max_output_tokens、tools 与 reasoning_effort 可选。'], - notes: ['非流式响应为 object: "response",文本位于 output 中。', '流式响应依次发送 response.created、response.output_text.delta 与 response.completed。'], - example: buildGatewayResponsesCurlExample(baseUrl, modelId) + description: t('modelRoutes.guideResponsesDesc'), + fields: [ + t('modelRoutes.guideFieldModel', { modelId }), + t('modelRoutes.guideResponsesInput'), + t('modelRoutes.guideResponsesStream'), + t('modelRoutes.guideResponsesOptional') + ], + notes: [t('modelRoutes.guideResponsesNonStreaming'), t('modelRoutes.guideResponsesStreaming')], + example: buildGatewayResponsesCurlExample(baseUrl, modelId, t) } return { method: 'POST', path: '/chat/completions', - description: '以 OpenAI Chat Completions 风格生成对话回复,是多数 OpenAI 兼容 SDK 的默认接入方式。', - fields: [`model:必填,使用公开模型 ID,例如 ${modelId}。`, 'messages:必填且不能为空,支持 system、developer、user、assistant 与 tool 消息。', 'stream:可选;true 时返回 SSE,false 时返回完整 JSON。', 'tools:可选,使用 OpenAI function 工具定义;图片仅接受 Base64 data URL。'], - notes: ['非流式文本位于 choices[0].message.content。', '流式输出以 data: [DONE] 结束。', '模型不存在时返回 404(model_not_found)。'], - example: buildGatewayCurlExample(baseUrl, modelId) + description: t('modelRoutes.guideChatDesc'), + fields: [ + t('modelRoutes.guideFieldModel', { modelId }), + t('modelRoutes.guideChatMessages'), + t('modelRoutes.guideChatStream'), + t('modelRoutes.guideChatTools') + ], + notes: [ + t('modelRoutes.guideChatNonStreaming'), + t('modelRoutes.guideChatStreaming'), + t('modelRoutes.guideChatModelMissing') + ], + example: buildGatewayCurlExample(baseUrl, modelId, t) } } -function buildGatewayCurlExample(baseUrl: string, modelId: string): string { +function buildGatewayCurlExample(baseUrl: string, modelId: string, t: TFunction): string { return `curl --request POST ${baseUrl}/chat/completions \\ --header 'Content-Type: application/json' \\ --data '{ "model": "${modelId}", "messages": [ - { "role": "user", "content": "你好,Kun!" } + { "role": "user", "content": ${JSON.stringify(t('modelRoutes.exampleChatPrompt'))} } ], "stream": false }'` } -function buildGatewayResponsesCurlExample(baseUrl: string, modelId: string): string { +function buildGatewayResponsesCurlExample(baseUrl: string, modelId: string, t: TFunction): string { return `curl --request POST ${baseUrl}/responses \\ --header 'Content-Type: application/json' \\ --data '{ "model": "${modelId}", - "input": "用一句话介绍 Kun 本地中转站", + "input": ${JSON.stringify(t('modelRoutes.exampleResponsesPrompt'))}, "stream": false }'` } @@ -852,12 +893,12 @@ function runtimeConfigurationMatches( return expectedPools.length === runtimePools.length && expectedPools.every((pool, index) => runtimePoolMatches(pool, runtimePools[index])) } -function routeStatusError(body: string, status: number): string { +function routeStatusError(body: string, status: number, t: TFunction): string { try { const parsed = JSON.parse(body) as { error?: { message?: string }; message?: string } - return parsed.error?.message?.trim() || parsed.message?.trim() || `Kun Runtime 状态请求失败 (${status})` + return parsed.error?.message?.trim() || parsed.message?.trim() || t('modelRoutes.statusRequestFailed', { status }) } catch { - return body.trim() || `Kun Runtime 状态请求失败 (${status})` + return body.trim() || t('modelRoutes.statusRequestFailed', { status }) } } function chainTestBlockedReason(input: { @@ -868,24 +909,28 @@ function chainTestBlockedReason(input: { configurationSynced: boolean selectedHasExecutableTarget: boolean invalidTargetCount: number -}): string { - if (input.saveStatus === 'error') return '本地保存失败,请先重试保存;未持久化的配置不会用于链路测试。' - if (input.saveStatus === 'saving') return '配置正在保存到本地,保存完成并同步到 Kun Runtime 后即可测试。' +}, t: TFunction): string { + if (input.saveStatus === 'error') return t('modelRoutes.blockedSaveFailed') + if (input.saveStatus === 'saving') return t('modelRoutes.blockedSaving') if (!input.selectedHasExecutableTarget) { return input.invalidTargetCount > 0 - ? `当前路由有 ${input.invalidTargetCount} 个失效引用且没有可执行目标,请先替换供应商或模型。` - : '当前路由没有已启用的有效目标,请先添加或启用一个目标。' + ? t('modelRoutes.blockedInvalidTargets', { count: input.invalidTargetCount }) + : t('modelRoutes.blockedNoTargets') } if (!input.configurationSynced && input.runtimeSyncStatus?.state === 'failed') { - return `本地配置已保存,但 Kun Runtime 同步失败。${input.runtimeSyncStatus.message ? ` ${input.runtimeSyncStatus.message}` : '请检查 Runtime 日志后重试保存。'}` + return input.runtimeSyncStatus.message + ? t('modelRoutes.blockedSyncFailedWithMessage', { message: input.runtimeSyncStatus.message }) + : t('modelRoutes.blockedSyncFailed') } - if (!input.status) return `本地配置已保存,但 Kun Runtime 当前不可用;启动后会自动同步。${input.statusError ? ` ${input.statusError}` : ''}` - if (!input.configurationSynced) return '本地配置已保存,正在等待 Kun Runtime 应用相同的路由池和本地 API 状态。' - return 'Kun Runtime 尚未准备好执行该路由的完整链路测试。' + if (!input.status) return input.statusError + ? t('modelRoutes.blockedRuntimeUnavailableWithMessage', { message: input.statusError }) + : t('modelRoutes.blockedRuntimeUnavailable') + if (!input.configurationSynced) return t('modelRoutes.blockedWaitingForSync') + return t('modelRoutes.blockedRuntimeNotReady') } -function testStatusLabel(status: RoutePoolTestRecord['status']): string { return ({ queued: '等待执行', running: '测试进行中', succeeded: '链路测试成功', failed: '链路测试失败' })[status] } +function testStatusLabel(status: RoutePoolTestRecord['status'], t: TFunction): string { return t(`modelRoutes.testStatus.${status}`) } function testStatusClass(status: RoutePoolTestRecord['status']): string { return status === 'succeeded' ? 'bg-emerald-50 text-emerald-700' : status === 'failed' ? 'bg-red-50 text-red-700' : 'bg-accent/10 text-accent' } -function attemptStatusLabel(status: RoutePoolTestAttempt['status']): string { return ({ running: '测试中', succeeded: '成功', failed: '失败,已切换' })[status] } +function attemptStatusLabel(status: RoutePoolTestAttempt['status'], t: TFunction): string { return t(`modelRoutes.attemptStatus.${status}`) } function testProgress(test: RoutePoolTestRecord): number { if (test.status === 'succeeded' || test.status === 'failed') return 100 if (test.status === 'queued' || test.totalTargets === 0) return 4 diff --git a/src/renderer/src/i18n.ts b/src/renderer/src/i18n.ts index 52e7866aa..232fcc521 100644 --- a/src/renderer/src/i18n.ts +++ b/src/renderer/src/i18n.ts @@ -10,14 +10,17 @@ const englishGraphResources = Object.fromEntries( ) const englishGraphSettingsResources = Object.fromEntries( Object.entries(enSettings).filter(([key]) => - key.startsWith('graphSettings') || key.startsWith('storageRelocation') + key.startsWith('graphSettings') || + key.startsWith('storageRelocation') || + key.startsWith('modelRoutes') ) ) /** * Graph Mode launches with complete English and Chinese copy. Other active - * locales receive an explicit English Graph/Storage bundle so controls never - * render raw translation keys while native translations can be added incrementally. + * locales receive an explicit English Graph/Storage/Model Routes bundle so + * controls never render raw translation keys while native translations can be + * added incrementally. */ export function withGraphCommonFallback>(locale: T): T { return { @@ -29,11 +32,22 @@ export function withGraphCommonFallback>(local } export function withGraphSettingsFallback>(locale: T): T { - return { + const merged = { ...locale, ...Object.fromEntries( Object.entries(englishGraphSettingsResources).filter(([key]) => !(key in locale)) ) + } + + return { + ...Object.fromEntries( + Object.keys(enSettings) + .filter((key) => key in merged) + .map((key) => [key, merged[key]]) + ), + ...Object.fromEntries( + Object.entries(merged).filter(([key]) => !(key in enSettings)) + ) } as T } diff --git a/src/renderer/src/locales/en/common.json b/src/renderer/src/locales/en/common.json index 569b04565..993a94bf3 100644 --- a/src/renderer/src/locales/en/common.json +++ b/src/renderer/src/locales/en/common.json @@ -470,6 +470,8 @@ "designPrototypePlayUnavailable": "Create at least one screen before playing the prototype", "designPrototypeBack": "Back", "designPrototypeClose": "Close prototype", + "designPrototypeOpenBrowser": "Open in browser", + "designPrototypeOpenBrowserFailed": "Could not open the prototype in a browser.", "designPrototypeViewportMode": "Prototype viewport", "designPrototypeViewportWebDetail": "{{width}} x {{height}} web prototype", "designPrototypeViewportAppDetail": "{{width}} x {{height}} phone prototype", diff --git a/src/renderer/src/locales/en/settings.json b/src/renderer/src/locales/en/settings.json index 3cc7a6059..ce1c48b5a 100644 --- a/src/renderer/src/locales/en/settings.json +++ b/src/renderer/src/locales/en/settings.json @@ -358,6 +358,170 @@ "modelProviderWorkspaceTabs": "Provider settings tabs", "modelProviderModeProviders": "Model providers", "modelProviderModeRoutes": "Advanced local gateway", + "modelRoutes": { + "strategyPriority": "Priority failover", + "strategyRoundRobin": "Round robin", + "strategyWeightedRoundRobin": "Weighted round robin", + "strategyLeastLatency": "Lowest latency", + "strategyAdaptive": "Stability-first adaptive", + "emptyTitle": "Add your first routed model", + "gatewayMultipleModelsDesc": "One local relay provider can contain multiple public models.", + "addModel": "Add model", + "defaultRouteName": "Routed model {{index}}", + "testCreateFailed": "Unable to create the route test", + "testButtonCreating": "Creating test", + "testButtonInProgress": "Test in progress", + "testButtonFixSave": "Fix the save failure first", + "testButtonWaitSave": "Waiting for local save", + "testButtonEnableFirst": "Enable to test", + "testButtonFixInvalidTargets": "Fix invalid targets to test", + "runtimeUnavailable": "Kun Runtime unavailable", + "testButtonWaitSync": "Waiting for configuration sync", + "testButtonRun": "Test complete route", + "localSaveSaving": "Saving locally", + "localSaveFailed": "Local save failed", + "localSaveComplete": "Saved locally", + "runtimeSynced": "Kun Runtime synced", + "runtimeSyncFailed": "Kun Runtime sync failed", + "runtimeNotRunning": "Kun Runtime not running", + "runtimeNotConnected": "Kun Runtime not connected", + "runtimeSyncing": "Syncing to Kun Runtime", + "runtimeWaitingForSync": "Waiting for Kun Runtime sync", + "tabsAria": "Model route settings", + "tabGateway": "Gateway & API", + "tabModels": "Models & targets", + "tabResilience": "Resilience", + "tabMonitoring": "Validation & monitoring", + "localRelayProvider": "Local relay provider", + "enabledModelCount": "{{enabled}} / {{total}} models enabled", + "providerNameAria": "Relay provider name", + "providerDesc": "One provider serves multiple public models, each with independent route targets and load strategy.", + "enableLocalApi": "Enable local API", + "localOnlyNoAuth": "Local access only · No authentication", + "retrySave": "Retry save", + "runtimeUnavailableHint": "Local settings are unaffected and will sync automatically when Kun starts.", + "localApi": "Local API", + "localApiEnabledLocalOnly": "Enabled · Local access only", + "disabled": "Disabled", + "apiCompatibilityDesc": "Compatible with OpenAI Chat Completions and Responses. Public model IDs come from the routed models below.", + "copyLocalApiAddress": "Copy local API address", + "copied": "Copied", + "copy": "Copy", + "copyCurl": "Copy cURL", + "apiDocs": "API guide", + "routedModels": "Routed models", + "choosePool": "Select a model to configure its target pool.", + "availableTargets": "{{available}}/{{total}} available", + "invalidTargets": "{{count}} need repair", + "noModels": "No routed models yet", + "routedModel": "Routed model", + "routeModelNameAria": "Routed model name", + "hotUpdateHint": "Saved configuration hot-updates the active Kun Runtime.", + "enable": "Enable", + "enablePoolAria": "Enable route pool", + "publicModelId": "Public model ID", + "loadStrategy": "Load strategy", + "routeTargets": "Route targets", + "addTarget": "Add target", + "providerDeleted": "Provider deleted: {{providerId}}", + "originalModel": "Original model: {{modelId}}", + "modelDeleted": "Model deleted: {{modelId}}", + "weight": "Weight", + "notProbed": "Not probed", + "successCount": "{{successes}}/{{total}} successful", + "providerMissingWarning": "Provider {{providerId}} no longer exists. The reference was preserved; choose a replacement provider or delete this target.", + "modelMissingWarning": "Model {{modelId}} is no longer available from {{providerId}}. The reference was preserved; choose a replacement model or delete this target.", + "deleteModel": "Delete model", + "failoverRules": "Failover rules", + "networkError": "Network error", + "requestTimeout": "Request timeout", + "credentialError": "401 / 403 credential error", + "failoverStatuses": "Failover HTTP status codes", + "afterStreamNoRetry": "After streaming output begins, the request stops in place and will not retry or execute tools again.", + "healthCircuit": "Health & circuit breaking", + "consecutiveFailures": "Consecutive failures", + "cooldownSeconds": "Cooldown seconds", + "halfOpenProbes": "Half-open probes", + "routeValidation": "Route validation", + "routeValidationDesc": "Kun Runtime runs tests asynchronously. They continue after you leave and restore progress and results when you return.", + "blockedSaveFailed": "Local save failed. Retry the save first; unsaved configuration is not used for route tests.", + "blockedSaving": "Configuration is being saved locally. Testing is available after saving and Kun Runtime sync complete.", + "blockedInvalidTargets": "This route has {{count}} invalid references and no executable target. Replace the provider or model first.", + "blockedNoTargets": "This route has no enabled valid targets. Add or enable a target first.", + "blockedSyncFailed": "Local configuration was saved, but Kun Runtime sync failed. Check the Runtime logs and retry saving.", + "blockedSyncFailedWithMessage": "Local configuration was saved, but Kun Runtime sync failed. {{message}}", + "blockedRuntimeUnavailable": "Local configuration was saved, but Kun Runtime is unavailable and will sync automatically after startup.", + "blockedRuntimeUnavailableWithMessage": "Local configuration was saved, but Kun Runtime is unavailable and will sync automatically after startup. {{message}}", + "blockedWaitingForSync": "Local configuration was saved and is waiting for Kun Runtime to apply the same route pools and local API state.", + "blockedRuntimeNotReady": "Kun Runtime is not ready to test this complete route yet.", + "testStatus": { + "queued": "Waiting to run", + "running": "Test in progress", + "succeeded": "Route test succeeded", + "failed": "Route test failed" + }, + "attemptStatus": { + "running": "Testing", + "succeeded": "Succeeded", + "failed": "Failed, switched target" + }, + "attemptedTargets": "Attempted {{attempted}} / {{total}} targets", + "testingTarget": "Testing: {{target}}", + "finalTargetValue": "Final target: {{target}}", + "modelResponse": "Model response: {{response}}", + "noTests": "No route test records", + "currentTargetProgress": "Current target progress", + "order": "Order", + "target": "Target", + "status": "Status", + "latencyError": "Latency / error", + "recentTests": "Recent test records", + "time": "Time", + "result": "Result", + "attempts": "Attempts", + "finalTarget": "Final target", + "recentEvents": "Recent route events", + "latency": "Latency", + "noEvents": "No route events", + "apiDialogTitle": "Local API guide", + "apiDialogDesc": "OpenAI-compatible endpoints for local processes only; no Authorization header is required.", + "closeApiDocs": "Close API guide", + "endpoints": "Endpoints", + "modelList": "Model list", + "chatCompletions": "Chat Completions", + "responses": "Responses", + "baseUrlLabel": "Base URL", + "prerequisites": "Prerequisites", + "prerequisitesDesc": "Enable the local API and at least one routed model. Use its public model ID in the request's model field.", + "openAiCompatible": "OpenAI compatible", + "keyFields": "Key fields", + "responsesAndLimits": "Responses & limits", + "curlExample": "cURL example", + "copyExample": "Copy example", + "apiSecurityWarning": "This relay is not a public API service. It binds only to the local loopback address and currently has no authentication. Do not expose it to a LAN or the internet through port forwarding or an unprotected reverse proxy.", + "guideModelsDesc": "Lists all enabled routed models in the local relay. Each returned data[].id is the public model ID used by generation requests.", + "guideModelsNoBody": "No request body is required.", + "guideModelsEnabledOnly": "Only enabled routed models are returned; disabled or invalid pools are omitted.", + "guideModelsResponse": "A successful response uses the OpenAI-style object: \"list\" and a data array.", + "guideModelsDisabled": "Returns 404 (gateway_disabled) when the local API is disabled.", + "guideResponsesDesc": "Generates content in the OpenAI Responses format for clients that send input as a single request value.", + "guideFieldModel": "model: Required. Use a public model ID such as {{modelId}}.", + "guideResponsesInput": "input: Required. Accepts a string or message array.", + "guideResponsesStream": "stream: Optional. true returns Server-Sent Events.", + "guideResponsesOptional": "max_output_tokens, tools, and reasoning_effort are optional.", + "guideResponsesNonStreaming": "A non-streaming response uses object: \"response\" with text in output.", + "guideResponsesStreaming": "A streaming response emits response.created, response.output_text.delta, and response.completed in order.", + "guideChatDesc": "Generates a conversation response in the OpenAI Chat Completions format used by most OpenAI-compatible SDKs.", + "guideChatMessages": "messages: Required and non-empty. Supports system, developer, user, assistant, and tool messages.", + "guideChatStream": "stream: Optional. true returns SSE; false returns complete JSON.", + "guideChatTools": "tools: Optional. Uses OpenAI function tool definitions; images accept Base64 data URLs only.", + "guideChatNonStreaming": "Non-streaming text is in choices[0].message.content.", + "guideChatStreaming": "Streaming output ends with data: [DONE].", + "guideChatModelMissing": "An unknown model returns 404 (model_not_found).", + "exampleChatPrompt": "Hello, Kun!", + "exampleResponsesPrompt": "Introduce the Kun local gateway in one sentence.", + "statusRequestFailed": "Kun Runtime status request failed ({{status}})" + }, "modelProviderCompactSelect": "Choose provider", "modelProviderSearchPlaceholder": "Search providers…", "modelProviderSearchEmpty": "No providers match \"{{query}}\".", diff --git a/src/renderer/src/locales/zh/common.json b/src/renderer/src/locales/zh/common.json index d789515ce..4083ffaac 100644 --- a/src/renderer/src/locales/zh/common.json +++ b/src/renderer/src/locales/zh/common.json @@ -470,6 +470,8 @@ "designPrototypePlayUnavailable": "先创建至少一个 screen,才能播放原型", "designPrototypeBack": "返回", "designPrototypeClose": "关闭原型", + "designPrototypeOpenBrowser": "浏览器打开", + "designPrototypeOpenBrowserFailed": "无法在浏览器中打开原型。", "designPrototypeViewportMode": "原型视口", "designPrototypeViewportWebDetail": "{{width}} x {{height}} Web 交互稿", "designPrototypeViewportAppDetail": "{{width}} x {{height}} 手机交互稿", diff --git a/src/renderer/src/locales/zh/settings.json b/src/renderer/src/locales/zh/settings.json index e88f7f846..abcca93ff 100644 --- a/src/renderer/src/locales/zh/settings.json +++ b/src/renderer/src/locales/zh/settings.json @@ -358,6 +358,170 @@ "modelProviderWorkspaceTabs": "供应商设置标签页", "modelProviderModeProviders": "模型供应商", "modelProviderModeRoutes": "高级本地中转站", + "modelRoutes": { + "strategyPriority": "优先级故障转移", + "strategyRoundRobin": "轮询", + "strategyWeightedRoundRobin": "加权轮询", + "strategyLeastLatency": "最低延迟", + "strategyAdaptive": "稳定性优先自适应", + "emptyTitle": "添加第一个路由模型", + "gatewayMultipleModelsDesc": "一个本地中转供应商可以包含多个公开模型。", + "addModel": "添加模型", + "defaultRouteName": "路由模型 {{index}}", + "testCreateFailed": "无法创建链路测试", + "testButtonCreating": "正在创建测试", + "testButtonInProgress": "测试进行中", + "testButtonFixSave": "先修复保存失败", + "testButtonWaitSave": "等待本地保存", + "testButtonEnableFirst": "启用后可测试", + "testButtonFixInvalidTargets": "修复无效目标后测试", + "runtimeUnavailable": "Kun Runtime 不可用", + "testButtonWaitSync": "等待配置同步", + "testButtonRun": "测试完整链路", + "localSaveSaving": "正在保存到本地", + "localSaveFailed": "本地保存失败", + "localSaveComplete": "已保存到本地", + "runtimeSynced": "Kun Runtime 已同步", + "runtimeSyncFailed": "Kun Runtime 同步失败", + "runtimeNotRunning": "Kun Runtime 未运行", + "runtimeNotConnected": "Kun Runtime 未连接", + "runtimeSyncing": "正在同步到 Kun Runtime", + "runtimeWaitingForSync": "等待 Kun Runtime 同步", + "tabsAria": "模型路由设置", + "tabGateway": "网关与 API", + "tabModels": "模型与目标", + "tabResilience": "容错策略", + "tabMonitoring": "验证与监控", + "localRelayProvider": "本地中转供应商", + "enabledModelCount": "{{enabled}} / {{total}} 个模型已启用", + "providerNameAria": "中转供应商名称", + "providerDesc": "一个供应商统一承载多个公开模型,每个模型可配置独立的路由目标和负载策略。", + "enableLocalApi": "开放本地 API", + "localOnlyNoAuth": "仅本机访问 · 无鉴权", + "retrySave": "重试保存", + "runtimeUnavailableHint": "本地配置不受影响;Kun 启动后会自动同步。", + "localApi": "本地 API", + "localApiEnabledLocalOnly": "已启用 · 仅本机访问", + "disabled": "未启用", + "apiCompatibilityDesc": "兼容 OpenAI Chat Completions 与 Responses;公开模型 ID 由下方路由模型决定。", + "copyLocalApiAddress": "复制本地 API 地址", + "copied": "已复制", + "copy": "复制", + "copyCurl": "复制 cURL", + "apiDocs": "接口说明", + "routedModels": "路由模型", + "choosePool": "选择一个模型配置它的容量池。", + "availableTargets": "{{available}}/{{total}} 可用", + "invalidTargets": "{{count}} 个待修复", + "noModels": "还没有路由模型", + "routedModel": "路由模型", + "routeModelNameAria": "路由模型名称", + "hotUpdateHint": "配置保存后会热更新到当前 Kun Runtime。", + "enable": "启用", + "enablePoolAria": "启用路由池", + "publicModelId": "公开模型 ID", + "loadStrategy": "负载策略", + "routeTargets": "路由目标", + "addTarget": "添加目标", + "providerDeleted": "供应商已删除:{{providerId}}", + "originalModel": "原模型:{{modelId}}", + "modelDeleted": "模型已删除:{{modelId}}", + "weight": "权重", + "notProbed": "未探测", + "successCount": "{{successes}}/{{total}} 成功", + "providerMissingWarning": "供应商 {{providerId}} 已不存在;引用已保留,请选择替代供应商或删除目标。", + "modelMissingWarning": "模型 {{modelId}} 已不在 {{providerId}} 中;引用已保留,请选择替代模型或删除目标。", + "deleteModel": "删除模型", + "failoverRules": "故障转移规则", + "networkError": "网络错误", + "requestTimeout": "请求超时", + "credentialError": "401 / 403 凭据错误", + "failoverStatuses": "切换 HTTP 状态码", + "afterStreamNoRetry": "流式输出开始后固定停止,不会重复请求或执行工具。", + "healthCircuit": "健康与熔断", + "consecutiveFailures": "连续失败", + "cooldownSeconds": "冷却秒数", + "halfOpenProbes": "半开探测", + "routeValidation": "路由验证", + "routeValidationDesc": "测试由 Kun Runtime 异步执行,离开页面后仍会继续,返回时自动恢复进度和结果。", + "blockedSaveFailed": "本地保存失败,请先重试保存;未持久化的配置不会用于链路测试。", + "blockedSaving": "配置正在保存到本地,保存完成并同步到 Kun Runtime 后即可测试。", + "blockedInvalidTargets": "当前路由有 {{count}} 个失效引用且没有可执行目标,请先替换供应商或模型。", + "blockedNoTargets": "当前路由没有已启用的有效目标,请先添加或启用一个目标。", + "blockedSyncFailed": "本地配置已保存,但 Kun Runtime 同步失败。请检查 Runtime 日志后重试保存。", + "blockedSyncFailedWithMessage": "本地配置已保存,但 Kun Runtime 同步失败。{{message}}", + "blockedRuntimeUnavailable": "本地配置已保存,但 Kun Runtime 当前不可用;启动后会自动同步。", + "blockedRuntimeUnavailableWithMessage": "本地配置已保存,但 Kun Runtime 当前不可用;启动后会自动同步。{{message}}", + "blockedWaitingForSync": "本地配置已保存,正在等待 Kun Runtime 应用相同的路由池和本地 API 状态。", + "blockedRuntimeNotReady": "Kun Runtime 尚未准备好执行该路由的完整链路测试。", + "testStatus": { + "queued": "等待执行", + "running": "测试进行中", + "succeeded": "链路测试成功", + "failed": "链路测试失败" + }, + "attemptStatus": { + "running": "测试中", + "succeeded": "成功", + "failed": "失败,已切换" + }, + "attemptedTargets": "已尝试 {{attempted}} / {{total}} 个目标", + "testingTarget": "正在测试:{{target}}", + "finalTargetValue": "最终目标:{{target}}", + "modelResponse": "模型响应:{{response}}", + "noTests": "暂无链路测试记录", + "currentTargetProgress": "本次目标进度", + "order": "顺序", + "target": "目标", + "status": "状态", + "latencyError": "延迟 / 错误", + "recentTests": "最近测试记录", + "time": "时间", + "result": "结果", + "attempts": "尝试", + "finalTarget": "最终目标", + "recentEvents": "最近路由事件", + "latency": "延迟", + "noEvents": "暂无路由事件", + "apiDialogTitle": "本地 API 说明", + "apiDialogDesc": "OpenAI 兼容接口,仅允许本机进程访问;不需要 Authorization 请求头。", + "closeApiDocs": "关闭 API 说明", + "endpoints": "接口", + "modelList": "模型列表", + "chatCompletions": "Chat Completions", + "responses": "Responses", + "baseUrlLabel": "Base URL", + "prerequisites": "调用前提", + "prerequisitesDesc": "先启用本地 API,并至少启用一个路由模型。请求中的 model 使用它的公开模型 ID。", + "openAiCompatible": "OpenAI 兼容", + "keyFields": "关键字段", + "responsesAndLimits": "响应与限制", + "curlExample": "cURL 示例", + "copyExample": "复制示例", + "apiSecurityWarning": "此中转站不是面向公网的 API 服务:它仅绑定本机回环地址,且目前无鉴权。不要通过端口映射或未受保护的反向代理暴露给局域网或公网。", + "guideModelsDesc": "列出本地中转站中所有已启用的路由模型。返回的 data[].id 就是后续生成请求使用的公开模型 ID。", + "guideModelsNoBody": "无需请求体。", + "guideModelsEnabledOnly": "只返回已启用的路由模型;未启用或无效的容量池不会出现。", + "guideModelsResponse": "成功时返回 OpenAI 风格的 object: \"list\" 与 data 数组。", + "guideModelsDisabled": "本地 API 未启用时返回 404(gateway_disabled)。", + "guideResponsesDesc": "以 OpenAI Responses 风格生成内容,适合使用 input 作为单次输入的客户端。", + "guideFieldModel": "model:必填,使用公开模型 ID,例如 {{modelId}}。", + "guideResponsesInput": "input:必填,可传字符串或消息数组。", + "guideResponsesStream": "stream:可选;true 时返回 Server-Sent Events。", + "guideResponsesOptional": "max_output_tokens、tools 与 reasoning_effort 可选。", + "guideResponsesNonStreaming": "非流式响应为 object: \"response\",文本位于 output 中。", + "guideResponsesStreaming": "流式响应依次发送 response.created、response.output_text.delta 与 response.completed。", + "guideChatDesc": "以 OpenAI Chat Completions 风格生成对话回复,是多数 OpenAI 兼容 SDK 的默认接入方式。", + "guideChatMessages": "messages:必填且不能为空,支持 system、developer、user、assistant 与 tool 消息。", + "guideChatStream": "stream:可选;true 时返回 SSE,false 时返回完整 JSON。", + "guideChatTools": "tools:可选,使用 OpenAI function 工具定义;图片仅接受 Base64 data URL。", + "guideChatNonStreaming": "非流式文本位于 choices[0].message.content。", + "guideChatStreaming": "流式输出以 data: [DONE] 结束。", + "guideChatModelMissing": "模型不存在时返回 404(model_not_found)。", + "exampleChatPrompt": "你好,Kun!", + "exampleResponsesPrompt": "用一句话介绍 Kun 本地中转站", + "statusRequestFailed": "Kun Runtime 状态请求失败({{status}})" + }, "modelProviderCompactSelect": "选择供应商", "modelProviderSearchPlaceholder": "搜索供应商…", "modelProviderSearchEmpty": "没有匹配「{{query}}」的供应商。", diff --git a/src/renderer/src/store/chat-store-thread-actions.test.ts b/src/renderer/src/store/chat-store-thread-actions.test.ts index 0ce069fd9..4e41807a1 100644 --- a/src/renderer/src/store/chat-store-thread-actions.test.ts +++ b/src/renderer/src/store/chat-store-thread-actions.test.ts @@ -952,6 +952,38 @@ describe('chat-store-thread-actions queued messages', () => { expect(state.error).toBeNull() }) + it('guides a queued Design canvas message with its visible user text', async () => { + const steerUserMessage = vi.fn(async () => undefined) + registryMock.getProvider.mockReturnValue({ steerUserMessage }) + const { actions, state } = buildHarness() + state.currentTurnId = 'turn_design_active' + state.currentTurnUserId = 'user-design-original' + state.queuedMessages = [{ + id: 'q-design-guide', + text: 'Expanded internal Design prompt with canvas state and file instructions', + displayText: 'Make the title smaller', + guiDesignCanvas: true, + guiDesignMode: true, + agentSurface: 'design' + }] + + await expect(actions.guideQueuedMessage('q-design-guide')).resolves.toBe(true) + + expect(steerUserMessage).toHaveBeenCalledWith( + 'thr_existing', + 'turn_design_active', + 'Make the title smaller', + { displayText: 'Make the title smaller' } + ) + expect(state.queuedMessages).toEqual([]) + expect(state.blocks).toContainEqual(expect.objectContaining({ + kind: 'user', + id: 'q-design-guide', + turnId: 'turn_design_active', + text: 'Make the title smaller' + })) + }) + it('keeps guidance queued when the active delegated runtime cannot steer live', async () => { const steerUserMessage = vi.fn(async () => undefined) registryMock.getProvider.mockReturnValue({ steerUserMessage }) diff --git a/src/renderer/src/store/chat-store-thread-actions.ts b/src/renderer/src/store/chat-store-thread-actions.ts index 239468ae7..18af2d004 100644 --- a/src/renderer/src/store/chat-store-thread-actions.ts +++ b/src/renderer/src/store/chat-store-thread-actions.ts @@ -46,7 +46,7 @@ import type { ChatStoreSet, WriteAssistantMessageContext } from './chat-store-types' -import { canGuideQueuedMessage } from './queued-message-guidance' +import { queuedMessageGuidancePayload } from './queued-message-guidance' import { isPendingQueuedMessage, queuedMessagesForThread, @@ -876,7 +876,8 @@ export function createThreadActions( const state = get() const message = state.queuedMessages.find((candidate) => candidate.id === id) if (!message) return false - if (!canGuideQueuedMessage(message)) { + const guidance = queuedMessageGuidancePayload(message) + if (!guidance) { set({ error: i18n.t('common:guideQueuedMessageTextOnly') }) return false } @@ -911,7 +912,7 @@ export function createThreadActions( ? await useGraphStore.getState().steerSourceTurn( guidanceThreadId, guidanceTurnId, - message.text + guidance.text ) : false if (!graphSteered) { @@ -922,8 +923,8 @@ export function createThreadActions( await provider.steerUserMessage( guidanceThreadId, guidanceTurnId, - message.text, - message.displayText ? { displayText: message.displayText } : undefined + guidance.text, + guidance.displayText ? { displayText: guidance.displayText } : undefined ) } const requestCompletedAt = Date.now() @@ -943,12 +944,12 @@ export function createThreadActions( if (!stillQueued) return { error: null } const runtimeMessageAlreadyVisible = hasRuntimeUserBlockForGuidance( current.blocks, - message, + guidance, guidanceTurnId, requestStartedAt, requestCompletedAt ) - const displayText = message.displayText ?? message.text + const displayText = guidance.displayText ?? guidance.text return { queuedMessages: current.queuedMessages.filter((candidate) => candidate.id !== id), blocks: runtimeMessageAlreadyVisible @@ -962,8 +963,8 @@ export function createThreadActions( createdAt: new Date(requestCompletedAt).toISOString(), text: displayText, ...(message.modelLabel ? { modelLabel: message.modelLabel } : {}), - ...(message.displayText && message.displayText !== message.text - ? { meta: { displayText: message.displayText } } + ...(guidance.displayText && guidance.displayText !== guidance.text + ? { meta: { displayText: guidance.displayText } } : {}) } ], diff --git a/src/renderer/src/store/queued-message-guidance.test.ts b/src/renderer/src/store/queued-message-guidance.test.ts index a78b710d0..617cee20e 100644 --- a/src/renderer/src/store/queued-message-guidance.test.ts +++ b/src/renderer/src/store/queued-message-guidance.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { canGuideQueuedMessage } from './queued-message-guidance' +import { + canGuideQueuedMessage, + queuedMessageGuidancePayload +} from './queued-message-guidance' describe('canGuideQueuedMessage', () => { it('allows plain text queued during a plan-mode turn', () => { @@ -23,4 +26,41 @@ describe('canGuideQueuedMessage', () => { } })).toBe(false) }) + + it('uses visible Design canvas text instead of the expanded queued prompt for guidance', () => { + const message = { + id: 'q-design-text', + text: 'Internal Design prompt with canvas snapshots and generation instructions', + displayText: 'Make the title smaller', + guiDesignCanvas: true, + guiDesignMode: true, + agentSurface: 'design' as const + } + + expect(canGuideQueuedMessage(message)).toBe(true) + expect(queuedMessageGuidancePayload(message)).toEqual({ + text: 'Make the title smaller', + displayText: 'Make the title smaller' + }) + }) + + it('keeps targeted Design artifacts and canvas prompts without visible text queued', () => { + expect(canGuideQueuedMessage({ + id: 'q-design-svg', + text: 'Internal SVG prompt', + displayText: 'Animate the logo', + guiDesignMode: true, + guiDesignArtifact: { + kind: 'svg', + artifactId: 'logo', + relativePath: '.kun-design/logo/v1.svg' + } + })).toBe(false) + expect(canGuideQueuedMessage({ + id: 'q-design-internal-only', + text: 'Internal canvas prompt', + guiDesignCanvas: true, + guiDesignMode: true + })).toBe(false) + }) }) diff --git a/src/renderer/src/store/queued-message-guidance.ts b/src/renderer/src/store/queued-message-guidance.ts index cb6e5f2f2..1d926d9ee 100644 --- a/src/renderer/src/store/queued-message-guidance.ts +++ b/src/renderer/src/store/queued-message-guidance.ts @@ -1,17 +1,68 @@ import type { QueuedUserMessage } from './chat-store-types' -/** True when the text-only steer contract can preserve the whole queued payload. */ +export type QueuedMessageGuidanceInput = { + text: string + displayText?: string + attachmentIds?: readonly unknown[] + attachments?: readonly unknown[] + fileReferences?: readonly unknown[] + composerContexts?: readonly unknown[] + guiPlan?: unknown + guiDesignCanvas?: boolean + guiDesignMode?: boolean + guiDesignArtifact?: unknown + writeContext?: unknown +} + +export type QueuedMessageGuidancePayload = { + text: string + displayText?: string +} + +/** + * Resolve the text that can safely replace a queued send as live steering. + * Design canvas turns queue an expanded internal prompt plus renderer-only + * routing flags. The running Design turn already owns that canvas context, so + * its visible user text is the correct steering payload. + */ +export function queuedMessageGuidancePayload( + message: QueuedMessageGuidanceInput +): QueuedMessageGuidancePayload | null { + if ( + !message.text.trim() || + message.attachmentIds?.length || + message.attachments?.length || + message.fileReferences?.length || + message.composerContexts?.length || + message.guiPlan || + message.guiDesignArtifact || + message.writeContext + ) { + return null + } + + const hasDesignRouting = message.guiDesignCanvas === true || message.guiDesignMode === true + if (hasDesignRouting) { + const displayText = message.displayText?.trim() + if ( + message.guiDesignCanvas !== true || + message.guiDesignMode !== true || + !displayText + ) { + return null + } + return { text: displayText, displayText } + } + + const text = message.text.trim() + const displayText = message.displayText?.trim() + return { + text, + ...(displayText ? { displayText } : {}) + } +} + +/** True when the text-only steer contract can preserve the queued user intent. */ export function canGuideQueuedMessage(message: QueuedUserMessage): boolean { - return Boolean( - message.text.trim() && - !message.attachmentIds?.length && - !message.attachments?.length && - !message.fileReferences?.length && - !message.composerContexts?.length && - !message.guiPlan && - message.guiDesignCanvas !== true && - message.guiDesignMode !== true && - !message.guiDesignArtifact && - !message.writeContext - ) + return queuedMessageGuidancePayload(message) !== null } From 13adde1abd62e8c7f2fc6347887cd7659432798f Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Mon, 3 Aug 2026 22:47:32 +0800 Subject: [PATCH 2/6] feat(installer): add Windows installer syntax check and improve ACL handling - Introduced a new script `check-windows-installer-syntax.cjs` to validate Windows installer syntax. - Updated `package.json` to include the new syntax check in the build and distribution processes. - Enhanced PowerShell scripts for handling ACLs, ensuring proper environment variable usage for source and target paths. - Improved tests for Windows installer migration to validate new functionality and ensure robustness. --- build/windows-installer-migration.ps1 | 2 +- package.json | 5 +- scripts/check-windows-installer-syntax.cjs | 105 ++++++++++++++++++ src/main/cli-install-service.test.ts | 7 +- src/main/storage-relocation/engine.test.ts | 21 ++++ src/main/storage-relocation/engine.ts | 39 ++----- src/main/storage-relocation/paths.ts | 61 +++++++++- .../windows-powershell.test.ts | 99 +++++++++++++++++ src/main/windows-installer-migration.test.ts | 17 ++- src/shared/storage-relocation.ts | 3 +- 10 files changed, 311 insertions(+), 48 deletions(-) create mode 100644 scripts/check-windows-installer-syntax.cjs create mode 100644 src/main/storage-relocation/windows-powershell.test.ts diff --git a/build/windows-installer-migration.ps1 b/build/windows-installer-migration.ps1 index 69bdcc1d4..62fb72f6a 100644 --- a/build/windows-installer-migration.ps1 +++ b/build/windows-installer-migration.ps1 @@ -418,7 +418,7 @@ function Test-JournalAclSecure([string]$PathValue, [string]$Mode) { } return $true } catch { - Write-InstallerDiagnostic "Recovery journal ACL validation failed for $PathValue: $($_.Exception.Message)" + Write-InstallerDiagnostic "Recovery journal ACL validation failed for ${PathValue}: $($_.Exception.Message)" return $false } } diff --git a/package.json b/package.json index 0b5f00998..d625cc837 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "generate:extension-api-reference": "node ./scripts/generate-extension-api-reference.mjs", "check:extension-examples": "node ./scripts/check-extension-examples.mjs", "check:packaged-runtime-deps": "node ./scripts/check-packaged-runtime-dependencies.cjs", + "check:windows-installer-syntax": "node ./scripts/check-windows-installer-syntax.cjs", "check:package-size:mac:arm64": "node ./scripts/check-package-size.cjs --platform darwin --arch arm64 --enforce", "check:package-size:mac:x64": "node ./scripts/check-package-size.cjs --platform darwin --arch x64", "check:extension-release-gate": "npm run build:extensions && npm run build:kun && node --test ./scripts/after-pack.test.cjs ./scripts/check-package-size.test.cjs ./scripts/check-packaged-runtime-dependencies.test.cjs ./scripts/check-extension-release-execution.test.mjs ./scripts/ensure-macos-native-dependencies.test.cjs ./scripts/pack-bundled-extensions.test.mjs ./scripts/publish-r2.test.mjs ./scripts/smoke-packaged-cli.test.cjs ./scripts/smoke-packaged-extension-desktop.test.cjs ./scripts/smoke-packaged-extension-appimage.test.cjs ./scripts/smoke-packaged-ocr.test.cjs ./scripts/smoke-packaged-runtime-data-migration.test.cjs ./scripts/verify-extension-native-evidence.test.mjs ./scripts/verify-manual-extension-release.test.mjs ./scripts/verify-packaged-macos-native-architecture.test.cjs ./scripts/write-extension-native-evidence.test.mjs && node ./scripts/check-extension-release-gate.mjs", @@ -59,9 +60,9 @@ "test:kun": "npm --prefix kun test", "test:graph:platform": "node ./scripts/run-graph-platform-tests.mjs", "test:watch": "vitest", - "dist": "npm run build && npx --yes electron-builder@26.8.1 --config electron-builder.config.cjs --publish never", + "dist": "npm run check:windows-installer-syntax && npm run build && npx --yes electron-builder@26.8.1 --config electron-builder.config.cjs --publish never", "dist:dv": "node ./scripts/run-with-kun-flavor.cjs development npm run dist:dv:inner", - "dist:dv:inner": "npm run build && npx --yes electron-builder@26.8.1 --config electron-builder.config.cjs --publish never", + "dist:dv:inner": "npm run check:windows-installer-syntax && npm run build && npx --yes electron-builder@26.8.1 --config electron-builder.config.cjs --publish never", "dist:mac": "rm -f dist/Kun-*-mac-* dist/DeepSeek-GUI-*-mac-* dist/latest-mac.yml && npm run build && npm run dist:mac:x64:dmg && npm run dist:mac:x64:zip && npm run dist:mac:arm64:dmg && npm run dist:mac:arm64:zip && npm run check:package-size:mac:x64 && npm run check:package-size:mac:arm64 && node ./scripts/generate-mac-latest.cjs dist", "dist:mac:signed": "rm -f dist/Kun-*-mac-* dist/DeepSeek-GUI-*-mac-* dist/latest-mac.yml && MAC_SIGN=1 npm run dist:mac:x64 && MAC_SIGN=1 npm run dist:mac:arm64 && node ./scripts/generate-mac-latest.cjs dist", "dist:mac:arm64": "npm run build && npm run dist:mac:arm64:dmg && npm run dist:mac:arm64:zip && npm run check:package-size:mac:arm64", diff --git a/scripts/check-windows-installer-syntax.cjs b/scripts/check-windows-installer-syntax.cjs new file mode 100644 index 000000000..29c541595 --- /dev/null +++ b/scripts/check-windows-installer-syntax.cjs @@ -0,0 +1,105 @@ +'use strict' + +const { spawnSync } = require('node:child_process') +const { existsSync } = require('node:fs') +const { join } = require('node:path') + +const installerHelperPath = join( + __dirname, + '..', + 'build', + 'windows-installer-migration.ps1' +) + +function getWindowsPowerShellPath(env = process.env) { + const systemRoot = env.SystemRoot || env.WINDIR + if (!systemRoot) { + throw new Error('[windows-installer-syntax] SystemRoot is unavailable.') + } + return join( + systemRoot, + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' + ) +} + +function validateWindowsInstallerSyntax({ + platform = process.platform, + env = process.env, + spawn = spawnSync, + scriptPath = installerHelperPath +} = {}) { + if (platform !== 'win32') { + console.log('[windows-installer-syntax] Skipped outside Windows.') + return + } + + const powershellPath = getWindowsPowerShellPath(env) + if (!existsSync(powershellPath)) { + throw new Error( + `[windows-installer-syntax] Windows PowerShell was not found: ${powershellPath}` + ) + } + + const parseCommand = [ + '$tokens = $null', + '$errors = $null', + '[System.Management.Automation.Language.Parser]::ParseFile(' + + '$env:KUN_INSTALLER_SYNTAX_TARGET, [ref]$tokens, [ref]$errors) | Out-Null', + 'if ($errors.Count -gt 0) {', + ' foreach ($errorRecord in $errors) {', + ' [Console]::Error.WriteLine((', + ' "{0}:{1}:{2}: {3} [{4}]" -f', + ' $env:KUN_INSTALLER_SYNTAX_TARGET,', + ' $errorRecord.Extent.StartLineNumber,', + ' $errorRecord.Extent.StartColumnNumber,', + ' $errorRecord.Message,', + ' $errorRecord.ErrorId', + ' ))', + ' }', + ' exit 1', + '}' + ].join('\n') + + const result = spawn( + powershellPath, + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + parseCommand + ], + { + encoding: 'utf8', + env: { + ...env, + KUN_INSTALLER_SYNTAX_TARGET: scriptPath + } + } + ) + + if (result.stdout) process.stdout.write(result.stdout) + if (result.stderr) process.stderr.write(result.stderr) + if (result.error) throw result.error + if (result.status !== 0) { + throw new Error( + `[windows-installer-syntax] PowerShell parser exited with code ${result.status}.` + ) + } + + console.log(`[windows-installer-syntax] PowerShell syntax OK: ${scriptPath}`) +} + +if (require.main === module) { + validateWindowsInstallerSyntax() +} + +module.exports = { + getWindowsPowerShellPath, + installerHelperPath, + validateWindowsInstallerSyntax +} diff --git a/src/main/cli-install-service.test.ts b/src/main/cli-install-service.test.ts index e10035f9c..a9b0f4795 100644 --- a/src/main/cli-install-service.test.ts +++ b/src/main/cli-install-service.test.ts @@ -37,6 +37,7 @@ import { } from './cli-install-service' const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')! +const hostPlatform = process.platform describe('CLI install service on Linux', () => { let directory = '' @@ -82,8 +83,10 @@ describe('CLI install service on Linux', () => { expect(wrapper).toContain('# Kun CLI launcher') expect(wrapper).toContain(`app_image='${process.env.APPIMAGE}'`) expect(wrapper).toContain('KUN_CLI_ENTRY=1 exec "$app_image" "$@"') - expect((await lstat(commandPath)).mode & 0o111).not.toBe(0) - await expect(access(commandPath, constants.X_OK)).resolves.toBeUndefined() + if (hostPlatform !== 'win32') { + expect((await lstat(commandPath)).mode & 0o111).not.toBe(0) + await expect(access(commandPath, constants.X_OK)).resolves.toBeUndefined() + } const shellConfig = await readFile(join(directory, '.zshrc'), 'utf8') expect(shellConfig).toContain('# >>> Kun CLI >>>') diff --git a/src/main/storage-relocation/engine.test.ts b/src/main/storage-relocation/engine.test.ts index 3a4bb5354..7035882ce 100644 --- a/src/main/storage-relocation/engine.test.ts +++ b/src/main/storage-relocation/engine.test.ts @@ -2,6 +2,7 @@ import { lstat, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { STORAGE_RELOCATION_PROGRESS_MESSAGE_MAX_LENGTH } from '../../shared/storage-relocation' vi.mock('./paths', async (importOriginal) => { const actual = await importOriginal() @@ -153,4 +154,24 @@ describe('storage relocation engine recovery', () => { await writeFile(join(input.destinationD, 'conflict.txt'), 'do not replace') await expect(engine(input).preflightMove(input.destinationD)).rejects.toThrow(/empty folder/) }) + + it('truncates a persisted failure message before publishing recovery progress', async () => { + const relocation = engine(input) + const plan = await relocation.preflightMove(input.destinationD) + const prepared = await relocation.schedule(plan, false) + const longMessage = 'x'.repeat(STORAGE_RELOCATION_PROGRESS_MESSAGE_MAX_LENGTH + 1) + await relocation.store.writeJournal({ + ...prepared, + phase: 'failed', + error: { code: 'copy_failed', message: longMessage, nextActions: [] } + }) + + const recovered = await engine(input).status() + const expectedMessage = `${longMessage.slice(0, STORAGE_RELOCATION_PROGRESS_MESSAGE_MAX_LENGTH - 3)}...` + expect(recovered).toMatchObject({ + state: 'pending', + pending: { phase: 'failed', message: expectedMessage } + }) + expect(recovered.pending?.message).toHaveLength(STORAGE_RELOCATION_PROGRESS_MESSAGE_MAX_LENGTH) + }) }) diff --git a/src/main/storage-relocation/engine.ts b/src/main/storage-relocation/engine.ts index 437d5450d..031a334e1 100644 --- a/src/main/storage-relocation/engine.ts +++ b/src/main/storage-relocation/engine.ts @@ -1,5 +1,4 @@ import { createHash, randomUUID } from 'node:crypto' -import { execFile } from 'node:child_process' import { constants, createReadStream @@ -23,9 +22,9 @@ import { } from 'node:fs/promises' import { basename, dirname, join, relative, resolve, sep } from 'node:path' import { DatabaseSync } from 'node:sqlite' -import { promisify } from 'node:util' import { STORAGE_RELOCATION_SCHEMA_VERSION, + STORAGE_RELOCATION_PROGRESS_MESSAGE_MAX_LENGTH, StorageRelocationOperationJournalSchema, StorageRelocationPreflightPlanSchema, StorageRelocationProgressSchema, @@ -47,6 +46,7 @@ import { STORAGE_RELOCATION_OWNERSHIP_MARKER, STORAGE_RELOCATION_ROOT_NAMES, backupRootPath, + copyWindowsAcls, hardenStorageDestinationAcl, inspectStorageRoot, inspectWindowsVolume, @@ -75,8 +75,6 @@ const TRANSIENT_RELATIVE_PATHS = new Set([ 'data/.runtime-discovery.lock', 'data/.kun-runtime-owner.json' ]) -const execFileAsync = promisify(execFile) - export type StorageRelocationEngineOptions = { homeDir: string userDataPath: string @@ -905,32 +903,6 @@ async function ensureDestinationForOperation(journal: StorageRelocationOperation } } -async function copyWindowsAcls(sourceRoot: string, targetRoot: string): Promise { - if (process.platform !== 'win32') return - const script = [ - '$source = (Get-Item -LiteralPath $args[0] -Force).FullName', - '$target = (Get-Item -LiteralPath $args[1] -Force).FullName', - 'Set-Acl -LiteralPath $target -AclObject (Get-Acl -LiteralPath $source)', - '$sourceRootItem = Get-Item -LiteralPath $source -Force', - '$targetRootItem = Get-Item -LiteralPath $target -Force', - '$targetRootItem.CreationTimeUtc = $sourceRootItem.CreationTimeUtc', - 'Get-ChildItem -LiteralPath $source -Force -Recurse | Where-Object { -not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) } | ForEach-Object {', - ' $relative = $_.FullName.Substring($source.Length).TrimStart("\\")', - ' $copy = Join-Path $target $relative', - ' if (Test-Path -LiteralPath $copy) {', - ' Set-Acl -LiteralPath $copy -AclObject (Get-Acl -LiteralPath $_.FullName)', - ' $copyItem = Get-Item -LiteralPath $copy -Force', - ' $copyItem.CreationTimeUtc = $_.CreationTimeUtc', - ' }', - '}' - ].join('; ') - await execFileAsync( - 'powershell.exe', - ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script, sourceRoot, targetRoot], - { windowsHide: true, timeout: 15 * 60_000, maxBuffer: 256 * 1024 } - ) -} - async function fingerprintTree(rootPath: string): Promise { const hash = createHash('sha256') const inventory: StorageTreeInventory = { files: 0, directories: 0, links: 0, bytes: 0 } @@ -1106,11 +1078,16 @@ function progressFromJournal( completedItems: journal.roots.filter((root) => root.activated || root.targetFingerprint).length, totalItems: journal.roots.length, cancellable: journal.phase === 'prepared' || journal.phase === 'copying' || journal.phase === 'verifying', - ...(journal.error ? { message: journal.error.message } : {}), + ...(journal.error ? { message: progressMessage(journal.error.message) } : {}), updatedAt: now.toISOString() }) } +function progressMessage(message: string): string { + if (message.length <= STORAGE_RELOCATION_PROGRESS_MESSAGE_MAX_LENGTH) return message + return `${message.slice(0, STORAGE_RELOCATION_PROGRESS_MESSAGE_MAX_LENGTH - 3)}...` +} + function locationFromJournal( journal: StorageRelocationOperationJournal, now: Date diff --git a/src/main/storage-relocation/paths.ts b/src/main/storage-relocation/paths.ts index ba19c845f..2a3ac5662 100644 --- a/src/main/storage-relocation/paths.ts +++ b/src/main/storage-relocation/paths.ts @@ -9,6 +9,10 @@ import type { } from '../../shared/storage-relocation' const execFileAsync = promisify(execFile) +const WINDOWS_STORAGE_RELOCATION_VOLUME_ROOT_ENV = 'KUN_STORAGE_RELOCATION_VOLUME_ROOT' +const WINDOWS_STORAGE_RELOCATION_DESTINATION_PATH_ENV = 'KUN_STORAGE_RELOCATION_DESTINATION_PATH' +const WINDOWS_STORAGE_RELOCATION_ACL_SOURCE_PATH_ENV = 'KUN_STORAGE_RELOCATION_ACL_SOURCE_PATH' +const WINDOWS_STORAGE_RELOCATION_ACL_TARGET_PATH_ENV = 'KUN_STORAGE_RELOCATION_ACL_TARGET_PATH' export const STORAGE_RELOCATION_ROOT_NAMES = ['.kun', '.deepseekgui'] as const export const STORAGE_RELOCATION_CONTROL_DIR = 'storage-relocation' @@ -93,14 +97,19 @@ export async function inspectWindowsVolume(path: string): Promise return { @@ -152,7 +161,7 @@ export async function ensureDestinationIsEmpty(path: string): Promise { export async function hardenStorageDestinationAcl(path: string): Promise { if (process.platform !== 'win32') return const script = [ - '$path = $args[0]', + '$path = $env:KUN_STORAGE_RELOCATION_DESTINATION_PATH', '$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()', '$acl = New-Object System.Security.AccessControl.DirectorySecurity', '$acl.SetOwner($identity.User)', @@ -170,8 +179,48 @@ export async function hardenStorageDestinationAcl(path: string): Promise { ].join('; ') await execFileAsync( 'powershell.exe', - ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script, path], - { windowsHide: true, timeout: 30_000, maxBuffer: 64 * 1024 } + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], + { + windowsHide: true, + timeout: 30_000, + maxBuffer: 64 * 1024, + env: { ...process.env, [WINDOWS_STORAGE_RELOCATION_DESTINATION_PATH_ENV]: path } + } + ) +} + +export async function copyWindowsAcls(sourceRoot: string, targetRoot: string): Promise { + if (process.platform !== 'win32') return + const script = [ + `$source = (Get-Item -LiteralPath $env:${WINDOWS_STORAGE_RELOCATION_ACL_SOURCE_PATH_ENV} -Force).FullName`, + `$target = (Get-Item -LiteralPath $env:${WINDOWS_STORAGE_RELOCATION_ACL_TARGET_PATH_ENV} -Force).FullName`, + 'Set-Acl -LiteralPath $target -AclObject (Get-Acl -LiteralPath $source)', + '$sourceRootItem = Get-Item -LiteralPath $source -Force', + '$targetRootItem = Get-Item -LiteralPath $target -Force', + '$targetRootItem.CreationTimeUtc = $sourceRootItem.CreationTimeUtc', + 'Get-ChildItem -LiteralPath $source -Force -Recurse | Where-Object { -not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) } | ForEach-Object {', + ' $relative = $_.FullName.Substring($source.Length).TrimStart("\\")', + ' $copy = Join-Path $target $relative', + ' if (Test-Path -LiteralPath $copy) {', + ' Set-Acl -LiteralPath $copy -AclObject (Get-Acl -LiteralPath $_.FullName)', + ' $copyItem = Get-Item -LiteralPath $copy -Force', + ' $copyItem.CreationTimeUtc = $_.CreationTimeUtc', + ' }', + '}' + ].join('; ') + await execFileAsync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], + { + windowsHide: true, + timeout: 15 * 60_000, + maxBuffer: 256 * 1024, + env: { + ...process.env, + [WINDOWS_STORAGE_RELOCATION_ACL_SOURCE_PATH_ENV]: sourceRoot, + [WINDOWS_STORAGE_RELOCATION_ACL_TARGET_PATH_ENV]: targetRoot + } + } ) } diff --git a/src/main/storage-relocation/windows-powershell.test.ts b/src/main/storage-relocation/windows-powershell.test.ts new file mode 100644 index 000000000..9a2a8c5e6 --- /dev/null +++ b/src/main/storage-relocation/windows-powershell.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const childProcessState = vi.hoisted(() => ({ + calls: [] as Array<{ + file: string + args: readonly string[] + options: { env?: NodeJS.ProcessEnv } + }> +})) + +const execFileMock = vi.hoisted(() => { + const execFile = () => undefined + Object.defineProperty(execFile, Symbol.for('nodejs.util.promisify.custom'), { + value: ( + file: string, + args: readonly string[], + options: { env?: NodeJS.ProcessEnv } + ) => { + childProcessState.calls.push({ file, args, options }) + return Promise.resolve({ + stdout: '{"root":"C:\\\\","driveType":"Fixed","fileSystem":"NTFS","availableBytes":1}', + stderr: '' + }) + } + }) + return execFile +}) + +vi.mock('node:child_process', () => ({ + execFile: execFileMock +})) + +import { + copyWindowsAcls, + hardenStorageDestinationAcl, + inspectWindowsVolume +} from './paths' + +const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')! + +describe('Windows storage relocation PowerShell invocation', () => { + beforeEach(() => { + childProcessState.calls.length = 0 + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', platformDescriptor) + }) + + it('passes the volume root through a private process environment', async () => { + await expect(inspectWindowsVolume('C:\\Users\\Ada')).resolves.toMatchObject({ + root: 'C:\\', + driveType: 'Fixed', + fileSystem: 'NTFS', + availableBytes: 1 + }) + + const { args, file, options } = onlyCall() + expect(file).toBe('powershell.exe') + expect(args.slice(0, 5)).toEqual(['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command']) + expect(args).toHaveLength(6) + expect(args[5]).toContain('$env:KUN_STORAGE_RELOCATION_VOLUME_ROOT') + expect(options.env).toMatchObject({ KUN_STORAGE_RELOCATION_VOLUME_ROOT: 'C:\\' }) + }) + + it('passes the destination ACL path through a private process environment', async () => { + await hardenStorageDestinationAcl('C:\\Kun Data; & unsafe') + + const { args, file, options } = onlyCall() + expect(file).toBe('powershell.exe') + expect(args.slice(0, 5)).toEqual(['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command']) + expect(args).toHaveLength(6) + expect(args[5]).toContain('$env:KUN_STORAGE_RELOCATION_DESTINATION_PATH') + expect(options.env).toMatchObject({ + KUN_STORAGE_RELOCATION_DESTINATION_PATH: 'C:\\Kun Data; & unsafe' + }) + }) + + it('passes both source and target ACL paths through a private process environment', async () => { + await copyWindowsAcls('C:\\Source; & unsafe', 'D:\\Target; & unsafe') + + const { args, file, options } = onlyCall() + expect(file).toBe('powershell.exe') + expect(args.slice(0, 5)).toEqual(['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command']) + expect(args).toHaveLength(6) + expect(args[5]).toContain('$env:KUN_STORAGE_RELOCATION_ACL_SOURCE_PATH') + expect(args[5]).toContain('$env:KUN_STORAGE_RELOCATION_ACL_TARGET_PATH') + expect(options.env).toMatchObject({ + KUN_STORAGE_RELOCATION_ACL_SOURCE_PATH: 'C:\\Source; & unsafe', + KUN_STORAGE_RELOCATION_ACL_TARGET_PATH: 'D:\\Target; & unsafe' + }) + }) +}) + +function onlyCall(): { file: string; args: readonly string[]; options: { env?: NodeJS.ProcessEnv } } { + expect(childProcessState.calls).toHaveLength(1) + return childProcessState.calls[0]! +} diff --git a/src/main/windows-installer-migration.test.ts b/src/main/windows-installer-migration.test.ts index 3bf58b2b6..e3bf8499e 100644 --- a/src/main/windows-installer-migration.test.ts +++ b/src/main/windows-installer-migration.test.ts @@ -141,22 +141,29 @@ windowsOnly('Windows installer migration helper', () => { }) it('lets an explicit target override a registered legacy branded source', () => { - const resultPath = join(makeTempRoot(), 'resolved-path.txt') + const root = makeTempRoot() + const source = join(root, 'Legacy', 'DeepSeek GUI') + const candidate = join(root, 'Chosen Apps') + mkdirSync(source, { recursive: true }) + mkdirSync(candidate, { recursive: true }) + const canonicalCandidate = realpathSync.native(candidate) + const resultPath = join(root, 'resolved-path.txt') const result = runHelper({ action: 'ResolvePath', - source: 'D:\\Legacy\\DeepSeek GUI', - candidate: 'E:\\Chosen Apps', + source, + candidate, candidateExplicit: true, resultPath }) expect(result.status, processError(result)).toBe(0) - expect(readFileSync(resultPath, 'utf16le')).toBe('E:\\Chosen Apps\\Kun') + expect(readFileSync(resultPath, 'utf16le')).toBe(join(canonicalCandidate, 'Kun')) }) it('uses development-flavor identity without appending or cleaning production Kun', () => { const root = makeTempRoot() const candidate = join(root, 'kun-dv') + mkdirSync(candidate, { recursive: true }) const resultPath = join(root, 'resolved-path.txt') const result = runHelper({ action: 'ResolvePath', @@ -168,7 +175,7 @@ windowsOnly('Windows installer migration helper', () => { }) expect(result.status, processError(result)).toBe(0) - expect(readFileSync(resultPath, 'utf16le')).toBe(candidate) + expect(readFileSync(resultPath, 'utf16le')).toBe(realpathSync.native(candidate)) }) it('writes a recovered install source to the explicit result path', () => { diff --git a/src/shared/storage-relocation.ts b/src/shared/storage-relocation.ts index fc52daae7..40d179039 100644 --- a/src/shared/storage-relocation.ts +++ b/src/shared/storage-relocation.ts @@ -3,6 +3,7 @@ import { z } from 'zod' export const STORAGE_RELOCATION_SCHEMA_VERSION = 1 as const export const STORAGE_RELOCATION_MINIMUM_RESERVE_BYTES = 5 * 1024 * 1024 * 1024 export const STORAGE_RELOCATION_RESERVE_RATIO = 0.1 +export const STORAGE_RELOCATION_PROGRESS_MESSAGE_MAX_LENGTH = 2_000 export const StorageRelocationPhaseSchema = z.enum([ 'prepared', @@ -84,7 +85,7 @@ export const StorageRelocationProgressSchema = z.object({ totalItems: z.number().int().nonnegative(), currentItem: z.string().max(2_048).optional(), cancellable: z.boolean(), - message: z.string().max(2_000).optional(), + message: z.string().max(STORAGE_RELOCATION_PROGRESS_MESSAGE_MAX_LENGTH).optional(), updatedAt: z.string().datetime() }).strict() export type StorageRelocationProgress = z.infer From f38e829d8ea1cab0b34e5f0af1151907236341fd Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Mon, 3 Aug 2026 23:55:48 +0800 Subject: [PATCH 3/6] feat(windows): implement Windows code signing and installer verification - Added support for Windows code signing in the Electron build configuration, requiring credentials and publisher name. - Enhanced GitHub Actions workflow to include Windows signing environment variables and verification steps. - Introduced a PowerShell script for verifying Windows code signatures and timestamps during the release process. - Updated installer scripts to validate payloads and ensure proper installation paths. - Improved tests for Windows installer migration and added checks for code signing verification. --- .github/workflows/release.yml | 26 ++- build/installer.nsh | 7 + build/windows-installer-migration.ps1 | 54 +++++- electron-builder.config.cjs | 38 +++++ kun/src/graph/graph-run-completion.ts | 36 +++- kun/src/graph/graph-scheduler.ts | 17 +- kun/src/server/graph-runtime-factory.test.ts | 112 ++++++++++--- kun/src/server/graph-runtime-factory.ts | 14 +- kun/src/server/graph-runtime-recovery.ts | 3 - .../graph-runtime-shutdown-recovery.test.ts | 151 +++++++++++++++-- kun/src/server/runtime-factory.ts | 4 +- scripts/ensure-kun-install.cjs | 19 ++- scripts/release-win.ps1 | 31 ++++ scripts/smoke-windows-installer-migration.ps1 | 37 +++++ scripts/verify-windows-signing.ps1 | 55 ++++++ src/main/cli-install-service.test.ts | 9 + src/main/cli-install-service.ts | 7 +- src/main/gui-updater.test.ts | 157 +++++++++++++++++- src/main/gui-updater.ts | 135 ++++++++++++++- src/main/index.ts | 40 ++++- src/main/packaged-install-health.test.ts | 54 ++++++ src/main/packaged-install-health.ts | 45 +++++ ...naged-runtime-shutdown-coordinator.test.ts | 50 ++++++ .../managed-runtime-shutdown-coordinator.ts | 37 ++++- src/main/windows-installer-migration.test.ts | 64 ++++++- src/renderer/src/components/Workbench.tsx | 4 +- .../components/graph/GraphModePanel.test.ts | 111 ++++++++++++- .../src/components/graph/GraphModePanel.tsx | 86 +++++++++- .../components/sdd/SddAssistantPanel.test.ts | 83 +++++++++ .../src/components/sdd/SddAssistantPanel.tsx | 8 + .../sdd/SddAssistantPanel.user-input.test.ts | 2 + .../workbench/WorkbenchRightPanel.tsx | 14 +- .../useWorkbenchRightPanelElement.tsx | 2 + .../useWorkbenchSddTurnController.test.ts | 25 ++- .../useWorkbenchSddTurnController.ts | 40 ++++- src/renderer/src/graph/graph-store.test.ts | 27 +++ src/renderer/src/graph/graph-store.ts | 15 +- 37 files changed, 1526 insertions(+), 93 deletions(-) create mode 100644 scripts/verify-windows-signing.ps1 create mode 100644 src/main/packaged-install-health.test.ts create mode 100644 src/main/packaged-install-health.ts create mode 100644 src/renderer/src/components/sdd/SddAssistantPanel.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 94ca89274..08f0f065c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -328,6 +328,11 @@ jobs: DEEPSEEK_GUI_UPDATE_CHANNEL: stable RELEASE_CHANNEL: stable CSC_IDENTITY_AUTO_DISCOVERY: 'false' + KUN_REQUIRE_WINDOWS_SIGNING: '1' + WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} + WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} + WIN_CSC_PUBLISHER_NAME: ${{ secrets.WIN_CSC_PUBLISHER_NAME }} + WIN_CSC_TIMESTAMP_SERVER: 'http://timestamp.digicert.com' steps: - name: Check out merge commit uses: actions/checkout@v4 @@ -357,8 +362,8 @@ jobs: - name: Install dependencies run: npm ci - - name: Test Runtime data migration on native Windows - run: npx vitest run src/main/kun-data-dir-paths.test.ts src/main/legacy-data-migration.test.ts src/main/runtime-data-dir-migration.test.ts src/main/runtime-data-dir-ownership.test.ts --reporter=dot + - name: Test Runtime and installer migration on native Windows + run: npx vitest run src/main/kun-data-dir-paths.test.ts src/main/legacy-data-migration.test.ts src/main/runtime-data-dir-migration.test.ts src/main/runtime-data-dir-ownership.test.ts src/main/windows-installer-migration.test.ts --reporter=dot - name: Test Graph Mode on native Windows run: npm run test:graph:platform @@ -369,6 +374,23 @@ jobs: - name: Build Windows installer run: npm run dist:win + - name: Verify Windows code signatures + shell: pwsh + run: | + $installer = @(Get-ChildItem -Path dist -Filter 'Kun-*-win-x64.exe' -File) + if ($installer.Count -ne 1) { + throw "Expected exactly one Windows installer, found $($installer.Count)." + } + & powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\verify-windows-signing.ps1 ` + -FilePath $installer.FullName, (Join-Path $PWD 'dist\win-unpacked\Kun.exe') + if ($LASTEXITCODE -ne 0) { + throw "Windows code-signing verification failed with exit code $LASTEXITCODE." + } + + - name: Smoke Windows installer migration + shell: pwsh + run: npm run smoke:windows-installer-migration + - name: Smoke packaged Kun terminal command run: >- npm run smoke:packaged-cli -- diff --git a/build/installer.nsh b/build/installer.nsh index 395676fbe..e680f4c4b 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -193,6 +193,13 @@ Var /GLOBAL KunInstallerStopResult Quit ${endif} + !insertmacro kunRunMigrationHelper ValidatePayload + ${if} $KunInstallerHelperExitCode != 0 + MessageBox MB_OK|MB_ICONSTOP "Kun installation is incomplete. No PATH changes were made; run the installer again to repair it.$\r$\n$KunInstallerHelperOutput" /SD IDOK + SetErrorLevel 2 + Quit + ${endif} + !insertmacro kunRunMigrationHelper UpdatePath ${if} $KunInstallerHelperExitCode != 0 DetailPrint "Kun could not update the user PATH: $KunInstallerHelperOutput" diff --git a/build/windows-installer-migration.ps1 b/build/windows-installer-migration.ps1 index 62fb72f6a..28f9e4e7b 100644 --- a/build/windows-installer-migration.ps1 +++ b/build/windows-installer-migration.ps1 @@ -1,6 +1,6 @@ param( [Parameter(Mandatory = $true)] - [ValidateSet('ResolvePath', 'ResolveSource', 'ResolveUpdateScope', 'ResolveUninstaller', 'StopProcesses', 'Recover', 'Prepare', 'FallbackCleanup', 'Restore', 'UpdatePath')] + [ValidateSet('ResolvePath', 'ResolveSource', 'ResolveUpdateScope', 'ResolveUninstaller', 'StopProcesses', 'Recover', 'Prepare', 'FallbackCleanup', 'Restore', 'ValidatePayload', 'UpdatePath')] [string]$Action, [string]$ResultPath = '' ) @@ -875,6 +875,55 @@ function Assert-PackagedApplicationPayload([string]$Source) { } } +function Get-ExpectedApplicationExecutable { + $configured = (Get-EnvironmentValue 'KUN_INSTALLER_APP_EXECUTABLE').Trim() + $executable = if ([string]::IsNullOrWhiteSpace($configured)) { + (Get-CanonicalLeaf) + '.exe' + } else { + $configured + } + if ([string]::IsNullOrWhiteSpace($executable) -or + -not [string]::Equals([IO.Path]::GetFileName($executable), $executable, [StringComparison]::Ordinal) -or + $executable.IndexOfAny([IO.Path]::GetInvalidFileNameChars()) -ge 0) { + throw "The configured application executable is invalid: $executable" + } + return $executable +} + +function Assert-NonEmptyPayloadFile([string]$PathValue, [string]$Label) { + if (-not (Test-Path -LiteralPath $PathValue -PathType Leaf)) { + throw "The installed Kun payload is missing ${Label}: $PathValue" + } + + $item = Get-Item -LiteralPath $PathValue -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "The installed Kun payload must not use a reparse point for ${Label}: $PathValue" + } + if ($item.Length -le 0) { + throw "The installed Kun payload is empty for ${Label}: $PathValue" + } +} + +function Assert-PackagedInstallPayload { + $target = Normalize-FullPath (Get-EnvironmentValue 'KUN_INSTALLER_TARGET') + if ([string]::IsNullOrWhiteSpace($target)) { + throw 'The installed Kun payload target is not configured.' + } + Assert-SafeInstallRoot $target 'Installed application root' + if (-not (Test-Path -LiteralPath $target -PathType Container)) { + throw "The installed Kun payload directory is missing: $target" + } + + Assert-NonEmptyPayloadFile (Join-Path $target (Get-ExpectedApplicationExecutable)) 'the application executable' + Assert-NonEmptyPayloadFile (Join-Path $target 'resources\app.asar') 'resources\app.asar' + Assert-NonEmptyPayloadFile ( + Join-Path $target 'resources\app.asar.unpacked\kun\dist\cli\serve-entry.js' + ) 'the unpacked Kun runtime entry' + Assert-NonEmptyPayloadFile ( + Join-Path $target 'resources\app.asar.unpacked\kun\dist\manager\manager-entry.js' + ) 'the unpacked Kun service manager entry' +} + function Test-AppSpecificUninstaller([string]$Source) { if ([string]::IsNullOrWhiteSpace($Source)) { return $false @@ -1317,6 +1366,9 @@ try { Invoke-RestoreJournal Remove-EmptyLegacyContainers } + 'ValidatePayload' { + Assert-PackagedInstallPayload + } 'UpdatePath' { Update-UserPath } diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index 13f5cf8ed..ca6370d9f 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -49,6 +49,28 @@ const hasExplicitMacSigningIdentity = Boolean( process.env.MAC_SIGN === '1' ) +const requireWindowsCodeSigning = process.env.KUN_REQUIRE_WINDOWS_SIGNING === '1' +const hasWindowsSigningCredential = Boolean( + process.env.WIN_CSC_LINK?.trim() || + process.env.CSC_LINK?.trim() +) +const windowsPublisherName = (process.env.WIN_CSC_PUBLISHER_NAME || '').trim() +const windowsTimestampServer = ( + process.env.WIN_CSC_TIMESTAMP_SERVER || 'http://timestamp.digicert.com' +).trim() || 'http://timestamp.digicert.com' + +if (requireWindowsCodeSigning && !hasWindowsSigningCredential) { + throw new Error( + 'KUN_REQUIRE_WINDOWS_SIGNING=1 requires WIN_CSC_LINK (or CSC_LINK).' + ) +} + +if (requireWindowsCodeSigning && !windowsPublisherName) { + throw new Error( + 'KUN_REQUIRE_WINDOWS_SIGNING=1 requires WIN_CSC_PUBLISHER_NAME to match the signing certificate subject.' + ) +} + const hasNotaryToolCredentials = Boolean( process.env.APPLE_API_KEY_ID && process.env.APPLE_API_ISSUER && @@ -273,6 +295,22 @@ module.exports = { // the desktop render crisp icons at small sizes (#222). Regenerate with: // npx --yes png2icons src/asset/img/kun_mac.png build/icon -icowe -bc icon: './build/icon.ico', + // Release scripts set KUN_REQUIRE_WINDOWS_SIGNING=1. Keep developer and + // unsigned CI builds available, but make a release fail before publishing + // if the code-signing certificate cannot be used. + ...(requireWindowsCodeSigning + ? { + forceCodeSigning: true, + signtoolOptions: { + // electron-updater reads publisherName from app-update.yml when it + // verifies downloaded installers. The timestamp settings apply to + // both modern and legacy Authenticode signatures. + publisherName: windowsPublisherName, + rfc3161TimeStampServer: windowsTimestampServer, + timeStampServer: windowsTimestampServer + } + } + : {}), target: [{ target: 'nsis', arch: ['x64'] }] }, nsis: { diff --git a/kun/src/graph/graph-run-completion.ts b/kun/src/graph/graph-run-completion.ts index d6d550985..6bf5c606f 100644 --- a/kun/src/graph/graph-run-completion.ts +++ b/kun/src/graph/graph-run-completion.ts @@ -18,15 +18,26 @@ type CompletionOptions = { export async function tryCompleteGraphRun(initialRun: GraphRunV1, options: CompletionOptions): Promise { let run = initialRun - const required = Object.values(run.nodes).filter((node) => node.node.required && node.node.kind !== 'loop_gate') - if (!required.length || !required.every((node) => node.status === 'accepted' || node.status === 'superseded' || loopGateWaivesIncompleteNode(run, node.node.id))) return run + if (!graphRunCompletionGatesPassed(run)) return run if (options.mailbox.unresolvedBlockers(run).length) return run - if (!run.plans.at(-1)!.completionNodeIds.every((id) => run.nodes[id]?.status === 'accepted' || run.nodes[id]?.status === 'superseded' || loopGateWaivesIncompleteNode(run, id))) return run - if (Object.values(run.nodes).some((node) => ['pending', 'blocked', 'ready', 'queued', 'running', 'submitted', 'reviewing'].includes(node.status))) return run run = await options.transitionRun(run, 'completing', 'all completion gates passed') return finishGraphRun(run, options) } +export function graphRunCompletionGatesPassed(run: GraphRunV1): boolean { + const required = Object.values(run.nodes).filter((node) => node.node.required && node.node.kind !== 'loop_gate') + if (!required.length || !required.every((node) => + node.status === 'accepted' || + node.status === 'superseded' || + loopGateWaivesIncompleteNode(run, node.node.id))) return false + if (!run.plans.at(-1)!.completionNodeIds.every((id) => + run.nodes[id]?.status === 'accepted' || + run.nodes[id]?.status === 'superseded' || + loopGateWaivesIncompleteNode(run, id))) return false + return !Object.values(run.nodes).some((node) => + ['pending', 'blocked', 'ready', 'queued', 'running', 'submitted', 'reviewing'].includes(node.status)) +} + export async function finishGraphRun(initialRun: GraphRunV1, options: CompletionOptions): Promise { let run = initialRun if (run.status !== 'completing') return run @@ -46,3 +57,20 @@ export async function finishGraphRun(initialRun: GraphRunV1, options: Completion await options.onTerminal?.(run) return run } + +/** + * Summary persistence is the first durable step of terminal finalization. + * A later cleanup or status-transition failure may temporarily move the run + * back to supervision. Only summaries whose current revision still satisfies + * the normal completion gates receive that protection. + */ +export function isGraphRunCompletionFinalizing( + run: GraphRunV1, + mailbox: Pick +): boolean { + return run.status === 'completing' || ( + run.summary !== undefined && + graphRunCompletionGatesPassed(run) && + mailbox.unresolvedBlockers(run).length === 0 + ) +} diff --git a/kun/src/graph/graph-scheduler.ts b/kun/src/graph/graph-scheduler.ts index 0a9afdd71..70091a31e 100644 --- a/kun/src/graph/graph-scheduler.ts +++ b/kun/src/graph/graph-scheduler.ts @@ -25,7 +25,11 @@ import { loopGateWaivesIncompleteNode } from './graph-loop-policy.js' import { evaluateGraphLoopGates } from './graph-loop-gate-evaluator.js' -import { finishGraphRun, tryCompleteGraphRun } from './graph-run-completion.js' +import { + finishGraphRun, + isGraphRunCompletionFinalizing, + tryCompleteGraphRun +} from './graph-run-completion.js' import { reconcileGraphReadiness } from './graph-readiness-reconciler.js' import type { GraphSchedulerOptions, @@ -209,6 +213,17 @@ export class GraphScheduler extends GraphAttemptScheduler { ) return run if (run.status === 'completing') return this.finishCompletion(run) run = await this.options.mailbox.expire(run, `scheduler_${run.id}_${run.lastEventSeq}`) + if ( + (run.status === 'awaiting_supervision' || run.status === 'awaiting_human') && + isGraphRunCompletionFinalizing(run, this.options.mailbox) + ) { + run = await this.transitionRun( + run, + 'completing', + 'retry persisted final synthesis after supervision interruption' + ) + return this.finishCompletion(run) + } run = await this.reconcileSubmitted(run, (request) => deferredSupervision.push(request)) const exhaustedRequiredNode = terminalRequiredFailure(run, this.options.config()) if (exhaustedRequiredNode) { diff --git a/kun/src/server/graph-runtime-factory.test.ts b/kun/src/server/graph-runtime-factory.test.ts index bdb922b4c..ff4b31144 100644 --- a/kun/src/server/graph-runtime-factory.test.ts +++ b/kun/src/server/graph-runtime-factory.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { InMemoryThreadStore } from '../adapters/in-memory-thread-store.js' import { InMemoryArtifactStore } from '../artifacts/artifact-store.js' import type { GraphRuntimeConfig } from '../config/kun-config.js' +import { GRAPH_CONTRACT_VERSION, type GraphRunV1 } from '../contracts/graph.js' import { createThreadRecord } from '../domain/thread.js' import { createTurnRecord } from '../domain/turn.js' import { GraphRunConflictError } from '../graph/graph-run-store.js' @@ -21,6 +22,53 @@ afterEach(async () => { rm(root, { recursive: true, force: true }))) }) +async function transitionRun( + runtime: GraphRuntimeComposition, + run: GraphRunV1, + to: GraphRunV1['status'], + commandId: string +): Promise { + return (await runtime.store.append(run.id, { + expectedSeq: run.lastEventSeq, + graphRevision: run.currentRevision, + commandId, + idempotencyKey: commandId, + event: { + type: 'run_status_changed', + payload: { from: run.status, to } + } + })).state +} + +async function recordFinalSummary( + runtime: GraphRuntimeComposition, + run: GraphRunV1, + commandId: string +): Promise { + return (await runtime.store.append(run.id, { + expectedSeq: run.lastEventSeq, + graphRevision: run.currentRevision, + commandId, + idempotencyKey: commandId, + event: { + type: 'run_summary_recorded', + payload: { + summary: { + version: GRAPH_CONTRACT_VERSION, + finalAnswer: 'A stale Graph report was persisted before later work.', + evidenceRefs: [], + unresolvedRisks: [], + changedFiles: [], + validationResults: [], + totalTokens: 0, + totalElapsedMs: 0, + completedAt: '2026-07-26T00:00:00.000Z' + } + } + } + })).state +} + describe('GraphRuntimeComposition creation authority', () => { it('binds HTTP/tool creation inputs to the canonical parent thread and source turn', async () => { const root = await mkdtemp(join(tmpdir(), 'kun-graph-runtime-authority-')) @@ -100,27 +148,53 @@ describe('GraphRuntimeComposition creation authority', () => { })).resolves.toMatchObject({ run: { status: 'ready' } }) let completing = await runtime.control.get('run_valid') - completing = (await runtime.store.append(completing.id, { - expectedSeq: completing.lastEventSeq, - graphRevision: completing.currentRevision, - commandId: 'start_run_valid', - idempotencyKey: 'start_run_valid', - event: { - type: 'run_status_changed', - payload: { from: 'ready', to: 'running' } - } - })).state - await runtime.store.append(completing.id, { - expectedSeq: completing.lastEventSeq, - graphRevision: completing.currentRevision, - commandId: 'complete_run_valid', - idempotencyKey: 'complete_run_valid', - event: { - type: 'run_status_changed', - payload: { from: 'running', to: 'completing' } - } + completing = await transitionRun(runtime, completing, 'running', 'start_run_valid') + completing = await transitionRun(runtime, completing, 'completing', 'complete_run_valid') + await runtime.handleSourceTurnTerminal(thread.id, 'turn_1', 'aborted') + await expect(runtime.control.get('run_valid')).resolves.toMatchObject({ + status: 'completing' + }) + + // A stale summary must not fence later unfinished Graph work from a + // passive source-turn cancellation. + await runtime.control.create({ + ...base, + runId: 'run_summarized', + commandId: 'command_create_summarized', + idempotencyKey: 'create_summarized' + }) + let summarized = await runtime.control.get('run_summarized') + summarized = await transitionRun(runtime, summarized, 'running', 'start_run_summarized') + summarized = await transitionRun(runtime, summarized, 'completing', 'complete_run_summarized') + summarized = await recordFinalSummary(runtime, summarized, 'summarize_run_summarized') + summarized = await transitionRun( + runtime, + summarized, + 'awaiting_supervision', + 'hold_summarized_run_for_recovery' + ) + await runtime.handleSourceTurnTerminal(thread.id, 'turn_1', 'failed') + await expect(runtime.control.get('run_summarized')).resolves.toMatchObject({ + status: 'cancelled', + summary: { finalAnswer: 'A stale Graph report was persisted before later work.' } }) + + await runtime.control.create({ + ...base, + runId: 'run_active', + commandId: 'command_create_active', + idempotencyKey: 'create_active' + }) + let active = await runtime.control.get('run_active') + active = await transitionRun(runtime, active, 'running', 'start_run_active') await runtime.handleSourceTurnTerminal(thread.id, 'turn_1', 'aborted') + await expect(runtime.control.get(active.id)).resolves.toMatchObject({ + status: 'cancelled' + }) + + await runtime.handleSourceTurnTerminal(thread.id, 'turn_1', 'aborted', { + forceCancel: true + }) await expect(runtime.control.get('run_valid')).resolves.toMatchObject({ status: 'cancelled' }) diff --git a/kun/src/server/graph-runtime-factory.ts b/kun/src/server/graph-runtime-factory.ts index d998321af..387295cb7 100644 --- a/kun/src/server/graph-runtime-factory.ts +++ b/kun/src/server/graph-runtime-factory.ts @@ -35,6 +35,7 @@ import type { SessionStore } from '../ports/session-store.js' import type { ThreadStore } from '../ports/thread-store.js' import type { RuntimeEventRecorder } from '../services/runtime-event-recorder.js' import { createGraphCheckVerifier } from '../graph/graph-check-verifier.js' +import { isGraphRunCompletionFinalizing } from '../graph/graph-run-completion.js' import { recoverGraphLeadOwnership, recoverGraphPlanningCommits @@ -421,7 +422,8 @@ export class GraphRuntimeComposition { async handleSourceTurnTerminal( threadId: string, sourceTurnId: string, - status: 'completed' | 'failed' | 'aborted' + status: 'completed' | 'failed' | 'aborted', + options: { forceCancel?: boolean } = {} ): Promise { const runs = await this.store.list({ threadId }) for (const run of runs) { @@ -433,6 +435,16 @@ export class GraphRuntimeComposition { ) { continue } + if ( + options.forceCancel !== true && + isGraphRunCompletionFinalizing(run, this.mailbox) + ) { + // Scheduler owns idempotent cleanup and completion. Wake it when the + // source Lead has just failed so a persisted final summary can finish + // without needing another Lead episode. + await this.scheduler?.resumeRun(run.id) + continue + } try { await this.control.cancel(run.id, { commandId: this.options.ids.next('graph_source_turn_terminal'), diff --git a/kun/src/server/graph-runtime-recovery.ts b/kun/src/server/graph-runtime-recovery.ts index 8f9a43574..cabed2db9 100644 --- a/kun/src/server/graph-runtime-recovery.ts +++ b/kun/src/server/graph-runtime-recovery.ts @@ -119,9 +119,6 @@ export async function recoverGraphLeadOwnership( if (!sourceTurn) continue const terminal = isTerminal(run) if (sourceTurn.status !== 'running') { - // Older runtimes persisted host-shutdown aborts before stopping. Once all - // completion gates pass, the idempotent scheduler may finish synthesis. - if (sourceTurn.status === 'aborted' && run.status === 'completing') continue if ( !terminal && (sourceTurn.status === 'completed' || diff --git a/kun/src/server/graph-runtime-shutdown-recovery.test.ts b/kun/src/server/graph-runtime-shutdown-recovery.test.ts index 672a7ff1a..807242eeb 100644 --- a/kun/src/server/graph-runtime-shutdown-recovery.test.ts +++ b/kun/src/server/graph-runtime-shutdown-recovery.test.ts @@ -74,7 +74,7 @@ async function waitForRun( } describe('Graph runtime shutdown recovery', () => { - it('does not cancel an aborted legacy owner while completing and finalizes once', async () => { + it('finishes a summarized run after its failed source turn is recovered', async () => { const root = await mkdtemp(join(tmpdir(), 'kun-graph-shutdown-recovery-')) const workspace = join(root, 'workspace') await mkdir(workspace) @@ -123,23 +123,45 @@ describe('Graph runtime shutdown recovery', () => { idempotencyKey: 'create_shutdown_recovery', start: true }) - await seed.store.append(created.run.id, { - expectedSeq: created.run.lastEventSeq, - graphRevision: created.run.currentRevision, - commandId: 'enter_completing', - idempotencyKey: 'enter_completing', - event: { - type: 'run_status_changed', - payload: { - from: 'running', - to: 'completing', - reason: 'all completion gates passed' + let run = created.run + for (const node of run.plans.at(-1)!.nodes) { + run = await acceptPersistedNode(seed, run, node.id, workspace) + } + run = await appendGraphEvent(seed, run, 'enter_completing', { + type: 'run_status_changed', + payload: { + from: 'running', + to: 'completing', + reason: 'all completion gates passed' + } + }) + run = await appendGraphEvent(seed, run, 'persist_final_summary', { + type: 'run_summary_recorded', + payload: { + summary: { + version: GRAPH_CONTRACT_VERSION, + finalAnswer: 'The accepted Graph report is durable.', + evidenceRefs: [], + unresolvedRisks: [], + changedFiles: [], + validationResults: [], + totalTokens: 0, + totalElapsedMs: 0, + completedAt: '2026-07-30T15:00:00.000Z' } - } - }) + } + }) + await appendGraphEvent(seed, run, 'hold_final_summary_for_recovery', { + type: 'run_status_changed', + payload: { + from: 'completing', + to: 'awaiting_supervision', + reason: 'scheduler recovery required after summary persistence' + } + }) await threadStore.upsert({ ...thread, - turns: [{ ...sourceTurn, status: 'aborted' }] + turns: [{ ...sourceTurn, status: 'failed' }] }) const firstRestart = composition() @@ -168,7 +190,7 @@ describe('Graph runtime shutdown recovery', () => { entry.event.type === 'run_status_changed' && entry.event.payload.to === 'cancelled')).toHaveLength(0) expect((await threadStore.get(thread.id))?.turns[0]).toMatchObject({ - status: 'aborted' + status: 'failed' }) expect(leadTurn).not.toHaveBeenCalled() await secondRestart.stop() @@ -682,3 +704,100 @@ async function appendGraphEvent( event })).state } + +async function acceptPersistedNode( + runtime: GraphRuntimeComposition, + run: GraphRunV1, + nodeId: string, + workspace: string +): Promise { + let next = await appendGraphEvent(runtime, run, `${nodeId}_ready`, { + type: 'node_status_changed', + payload: { + nodeId, + from: 'pending', + to: 'ready', + reason: 'persisted accepted-result fixture' + } + }) + const attempt = GraphNodeAttemptV1Schema.parse({ + version: GRAPH_CONTRACT_VERSION, + id: `attempt_${nodeId}_accepted`, + runId: next.id, + nodeId, + revision: next.currentRevision, + attemptNumber: 1, + iteration: 0, + commandId: `attempt_${nodeId}_accepted`, + idempotencyKey: `attempt_${nodeId}_accepted`, + status: 'queued', + assignment: { + ...testAssignmentSnapshot(), + workspaceRoot: workspace + }, + queuedAt: '2026-07-30T15:00:00.000Z', + tokenUsage: 0, + elapsedMs: 0 + }) + const events = [ + { type: 'attempt_created' as const, payload: { attempt } }, + { + type: 'attempt_status_changed' as const, + payload: { + nodeId, + attemptId: attempt.id, + from: 'queued' as const, + to: 'running' as const + } + }, + { + type: 'node_status_changed' as const, + payload: { + nodeId, + from: 'queued' as const, + to: 'running' as const, + reason: 'persisted accepted-result fixture' + } + }, + { + type: 'attempt_status_changed' as const, + payload: { + nodeId, + attemptId: attempt.id, + from: 'running' as const, + to: 'submitted' as const + } + }, + { + type: 'node_status_changed' as const, + payload: { + nodeId, + from: 'running' as const, + to: 'submitted' as const, + reason: 'persisted accepted-result fixture' + } + }, + { + type: 'attempt_status_changed' as const, + payload: { + nodeId, + attemptId: attempt.id, + from: 'submitted' as const, + to: 'accepted' as const + } + }, + { + type: 'node_status_changed' as const, + payload: { + nodeId, + from: 'submitted' as const, + to: 'accepted' as const, + reason: 'persisted accepted-result fixture' + } + } + ] + for (const [index, event] of events.entries()) { + next = await appendGraphEvent(runtime, next, `${nodeId}_accepted_${index}`, event) + } + return next +} diff --git a/kun/src/server/runtime-factory.ts b/kun/src/server/runtime-factory.ts index 11913b9c5..356ce886b 100644 --- a/kun/src/server/runtime-factory.ts +++ b/kun/src/server/runtime-factory.ts @@ -930,7 +930,9 @@ export async function createKunServeRuntime( transitionGraphPlanningDraft: (input) => graphRuntime.transitionPlanningDraft(input), cancelGraphSourceRuns: ({ threadId, sourceTurnId }) => - graphRuntime.handleSourceTurnTerminal(threadId, sourceTurnId, 'aborted'), + graphRuntime.handleSourceTurnTerminal(threadId, sourceTurnId, 'aborted', { + forceCancel: true + }), migrationMaintenance, ids, nowIso diff --git a/scripts/ensure-kun-install.cjs b/scripts/ensure-kun-install.cjs index 80b30db9f..748752936 100644 --- a/scripts/ensure-kun-install.cjs +++ b/scripts/ensure-kun-install.cjs @@ -1,5 +1,6 @@ const { existsSync, rmSync } = require('node:fs') const { spawnSync } = require('node:child_process') +const { dirname, join } = require('node:path') const REQUIRED_PATHS = [ 'kun/package-lock.json', @@ -24,7 +25,7 @@ const KUN_SQLITE_MODULE_PATH = 'kun/node_modules/better-sqlite3' function run(command, args) { return spawnSync(command, args, { stdio: 'inherit', - shell: process.platform === 'win32', + shell: false, env: { ...process.env, npm_config_audit: 'false', @@ -33,9 +34,23 @@ function run(command, args) { }) } +function npmInvocation(args) { + if (process.platform !== 'win32') return { command: 'npm', args } + const candidates = [ + process.env.npm_execpath, + join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js') + ].filter((candidate) => candidate && existsSync(candidate)) + const npmCli = candidates[0] + if (!npmCli) { + throw new Error('Unable to locate npm-cli.js for a shell-free Windows npm invocation.') + } + return { command: process.execPath, args: [npmCli, ...args] } +} + function ensureKunInstall() { if (!REQUIRED_PATHS.every((path) => existsSync(path))) { - const installKun = run('npm', ['--prefix', 'kun', 'ci']) + const npm = npmInvocation(['--prefix', 'kun', 'ci']) + const installKun = run(npm.command, npm.args) if (installKun.status !== 0) { process.exit(installKun.status || 1) } diff --git a/scripts/release-win.ps1 b/scripts/release-win.ps1 index 07acb2d97..3bc21aba3 100644 --- a/scripts/release-win.ps1 +++ b/scripts/release-win.ps1 @@ -51,6 +51,20 @@ function Require-Command([string]$Name) { } } +function Assert-WindowsSigningConfiguration { + $hasCredential = -not [string]::IsNullOrWhiteSpace($env:WIN_CSC_LINK) -or + -not [string]::IsNullOrWhiteSpace($env:CSC_LINK) + if (-not $hasCredential) { + Write-Err 'Windows release signing requires WIN_CSC_LINK (or CSC_LINK).' + exit 1 + } + + if ([string]::IsNullOrWhiteSpace($env:WIN_CSC_PUBLISHER_NAME)) { + Write-Err 'Windows release signing requires WIN_CSC_PUBLISHER_NAME to match the signing certificate subject.' + exit 1 + } +} + function Load-LocalReleaseEnv([string]$RootPath) { $configured = [Environment]::GetEnvironmentVariable('KUN_RELEASE_ENV', 'Process') if (-not $configured) { @@ -83,6 +97,8 @@ function Load-LocalReleaseEnv([string]$RootPath) { $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path Set-Location $Root Load-LocalReleaseEnv $Root +Assert-WindowsSigningConfiguration +$env:KUN_REQUIRE_WINDOWS_SIGNING = '1' if ($Stable -and $Frontier) { Write-Err 'Use only one of -Stable or -Frontier.' @@ -205,6 +221,21 @@ if ($LASTEXITCODE -ne 0) { exit 1 } +$installer = @(Get-ChildItem -Path (Join-Path $Root 'dist') -Filter 'Kun-*-win-x64.exe' -File) +if ($installer.Count -ne 1) { + Write-Err "Expected exactly one Windows installer, found $($installer.Count)." + exit 1 +} + +Write-Info 'Verifying Windows code signatures and secure timestamps...' +try { + & (Join-Path $Root 'scripts\verify-windows-signing.ps1') ` + -FilePath $installer.FullName, (Join-Path $Root 'dist\win-unpacked\Kun.exe') +} catch { + Write-Err "Windows code-signing verification failed: $($_.Exception.Message)" + exit 1 +} + Write-Info 'Smoking GUI-bundled Kun terminal command and shared version...' & npm run smoke:packaged-cli -- --resources dist/win-unpacked/resources --expected-version $ReleaseVersion if ($LASTEXITCODE -ne 0) { diff --git a/scripts/smoke-windows-installer-migration.ps1 b/scripts/smoke-windows-installer-migration.ps1 index 1fded1d82..45b9ab862 100644 --- a/scripts/smoke-windows-installer-migration.ps1 +++ b/scripts/smoke-windows-installer-migration.ps1 @@ -174,6 +174,17 @@ function Assert-PathReconciled([string]$ExpectedLocation, [string[]]$RejectedLoc } } +function Assert-PathEntryRemoved([string]$InstallLocation) { + $parts = @([Environment]::GetEnvironmentVariable('Path', 'User') -split ';' | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) + }) + $binPath = Join-Path $InstallLocation 'bin' + $remaining = @($parts | Where-Object { + [string]::Equals($_.TrimEnd('\'), $binPath.TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase) + }) + Assert-True ($remaining.Count -eq 0) "PATH entry remains after uninstall: $binPath" +} + function Get-ShortcutPaths([ValidateSet('CurrentUser', 'AllUsers')][string]$Scope) { if ($Scope -eq 'AllUsers') { return @( @@ -253,6 +264,31 @@ try { Add-DataSentinel (Join-Path $HOME '.kun') Add-DataSentinel (Join-Path $HOME '.deepseekgui') + $unicodeDirectoryName = -join @( + [char]0x4E2D # U+4E2D + [char]0x6587 # U+6587 + [char]0x20 + [char]0x5B89 # U+5B89 + [char]0x88C5 # U+88C5 + [char]0x76EE # U+76EE + [char]0x5F55 # U+5F55 + ) + $unicodeParent = Join-Path $root $unicodeDirectoryName + $unicodeTarget = Join-Path $unicodeParent 'Kun' + Invoke-Installer 'Unicode current-user install' @('/S', '/currentuser', ('"/D={0}"' -f $unicodeParent)) + Find-KunRegistration $unicodeTarget + Assert-RegisteredLocation $unicodeTarget + Assert-PathReconciled $unicodeTarget @() + $script:currentScenario = 'Unicode packaged CLI smoke' + & node (Join-Path $PSScriptRoot 'smoke-packaged-cli.cjs') '--resources' (Join-Path $unicodeTarget 'resources') + Assert-True ($LASTEXITCODE -eq 0) 'The packaged CLI did not run from the Unicode install directory.' + $script:currentScenario = 'Unicode current-user uninstall' + $unicodeUninstaller = Join-Path $unicodeTarget 'Uninstall Kun.exe' + $unicodeUninstall = Start-Process -FilePath $unicodeUninstaller -ArgumentList @('/S', '/currentuser') -Wait -PassThru + Assert-True ($unicodeUninstall.ExitCode -eq 0) "Unicode smoke uninstaller exited with $($unicodeUninstall.ExitCode)." + Assert-PathEntryRemoved $unicodeTarget + Assert-NoKunShortcuts 'CurrentUser' + $seedParent = Join-Path $root 'seed' $seed = Join-Path $seedParent 'Kun' Invoke-Installer 'seed current-user install' @('/S', '/currentuser', "/D=$seedParent") @@ -428,6 +464,7 @@ try { $machineUninstaller = Join-Path $machineTarget 'Uninstall Kun.exe' $machineUninstall = Start-Process -FilePath $machineUninstaller -ArgumentList @('/S', '/allusers') -Wait -PassThru Assert-True ($machineUninstall.ExitCode -eq 0) "All-users smoke uninstaller exited with $($machineUninstall.ExitCode)." + Assert-PathEntryRemoved $machineTarget foreach ($sentinel in $sentinels) { Assert-True (Test-Path -LiteralPath $sentinel) "All-users uninstall removed a user-data sentinel: $sentinel" } diff --git a/scripts/verify-windows-signing.ps1 b/scripts/verify-windows-signing.ps1 new file mode 100644 index 000000000..58e51f47c --- /dev/null +++ b/scripts/verify-windows-signing.ps1 @@ -0,0 +1,55 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string[]]$FilePath +) + +$ErrorActionPreference = 'Stop' + +function Find-SignTool { + $command = Get-Command 'signtool.exe' -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($command) { + return $command.Source + } + + $programFilesX86 = ${env:ProgramFiles(x86)} + if (-not $programFilesX86) { + $programFilesX86 = $env:ProgramFiles + } + $windowsKitsBin = Join-Path $programFilesX86 'Windows Kits\10\bin' + if (Test-Path -LiteralPath $windowsKitsBin -PathType Container) { + $candidate = Get-ChildItem -LiteralPath $windowsKitsBin -Filter 'signtool.exe' -File -Recurse ` + -ErrorAction SilentlyContinue | + Where-Object { $_.Directory.Name -in @('x64', 'x86') } | + Sort-Object -Property FullName -Descending | + Select-Object -First 1 + if ($candidate) { + return $candidate.FullName + } + } + + throw 'signtool.exe was not found. Install the Windows SDK signing tools or add signtool.exe to PATH.' +} + +$signTool = Find-SignTool +$resolvedFiles = foreach ($candidatePath in $FilePath) { + $item = Get-Item -LiteralPath $candidatePath -ErrorAction Stop + if ($item.PSIsContainer) { + throw "Expected a file to verify, got directory: $($item.FullName)" + } + if ($item.Length -le 0) { + throw "Cannot verify an empty file: $($item.FullName)" + } + $item.FullName +} + +foreach ($file in $resolvedFiles) { + Write-Host "Verifying Authenticode signature and timestamp: $file" + & $signTool verify /pa /all /tw /v $file + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + throw "signtool verification failed for $file (exit code $exitCode)." + } +} diff --git a/src/main/cli-install-service.test.ts b/src/main/cli-install-service.test.ts index a9b0f4795..d7fbe959b 100644 --- a/src/main/cli-install-service.test.ts +++ b/src/main/cli-install-service.test.ts @@ -224,6 +224,15 @@ describe('CLI install service on Windows', () => { expect(process.env.PATH).not.toContain(join(directory, 'bin')) }) + it('recognizes an existing Windows PATH entry with a trailing separator', async () => { + process.env.PATH = `${process.env.PATH};${join(directory, 'bin')}\\` + + await expect(cliInstallStatus()).resolves.toMatchObject({ + state: 'installed', + pathConfigured: true + }) + }) + it('refuses to enable a missing or non-regular packaged launcher', async () => { await rm(join(directory, 'bin', 'kun.cmd')) const missing = await runCliInstallAction('install') diff --git a/src/main/cli-install-service.ts b/src/main/cli-install-service.ts index d43affac1..026fba97c 100644 --- a/src/main/cli-install-service.ts +++ b/src/main/cli-install-service.ts @@ -289,9 +289,12 @@ function shellConfigPath(): { path: string; kind: 'posix' | 'fish' } | null { } function pathContains(path: string): boolean { - const expected = process.platform === 'win32' ? path.toLowerCase() : path + const normalize = (value: string): string => process.platform === 'win32' + ? value.trim().replace(/[\\/]+$/u, '').toLowerCase() + : value + const expected = normalize(path) return (process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':') - .some((entry) => (process.platform === 'win32' ? entry.toLowerCase() : entry) === expected) + .some((entry) => normalize(entry) === expected) } function errorCode(error: unknown): string { diff --git a/src/main/gui-updater.test.ts b/src/main/gui-updater.test.ts index 641d33f96..8a5faef59 100644 --- a/src/main/gui-updater.test.ts +++ b/src/main/gui-updater.test.ts @@ -6,6 +6,7 @@ type MockUpdater = EventEmitter & { autoDownload: boolean autoInstallOnAppQuit: boolean allowPrerelease: boolean + allowDowngrade: boolean forceDevUpdateConfig: boolean logger: unknown setFeedURL: ReturnType @@ -22,12 +23,15 @@ let appIsPackaged: boolean let mockedFiles: Map let showMessageBox: ReturnType let openExternal: ReturnType +let relaunchApp: ReturnType +let exitApp: ReturnType function createUpdater(): MockUpdater { return Object.assign(new EventEmitter(), { autoDownload: true, autoInstallOnAppQuit: true, allowPrerelease: false, + allowDowngrade: true, forceDevUpdateConfig: false, logger: null, setFeedURL: vi.fn(), @@ -48,6 +52,8 @@ beforeEach(() => { mockedFiles = new Map() showMessageBox = vi.fn().mockResolvedValue({ response: 1 }) openExternal = vi.fn().mockResolvedValue(undefined) + relaunchApp = vi.fn() + exitApp = vi.fn() vi.doMock('node:fs/promises', () => ({ mkdir: vi.fn().mockResolvedValue(undefined), readFile: vi.fn(async (path: string) => { @@ -67,7 +73,9 @@ beforeEach(() => { getAppPath: () => '/tmp/deepseek-gui-updater-test-app', getPath: () => '/tmp/deepseek-gui-updater-test-user-data', getVersion: () => appVersion, - getLocale: () => 'en-US' + getLocale: () => 'en-US', + relaunch: relaunchApp, + exit: exitApp }, autoUpdater: nativeUpdater, BrowserWindow: class {}, @@ -203,6 +211,15 @@ describe('checkGuiUpdate feed URL', () => { }) describe('installGuiUpdate', () => { + it('explicitly prevents automatic downgrade when changing update channels', async () => { + const module = await import('./gui-updater') + module.initializeGuiUpdater(() => null, () => 'frontier') + module.setGuiUpdateChannel('frontier') + module.setGuiUpdateChannel('stable') + + expect(updater.allowDowngrade).toBe(false) + }) + it('passes the running Windows install directory to the spawned NSIS updater', async () => { const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') const execPathDescriptor = Object.getOwnPropertyDescriptor(process, 'execPath') @@ -283,7 +300,10 @@ describe('installGuiUpdate', () => { await Promise.resolve() expect(beforeInstall).toHaveBeenCalledTimes(1) - expect(setUpdateInstallQuitting).not.toHaveBeenCalled() + expect(setUpdateInstallQuitting).toHaveBeenCalledWith(true) + expect(setUpdateInstallQuitting.mock.invocationCallOrder[0]).toBeLessThan( + beforeInstall.mock.invocationCallOrder[0] + ) expect(updater.quitAndInstall).not.toHaveBeenCalled() finishCleanup() @@ -328,7 +348,7 @@ describe('installGuiUpdate', () => { finishCleanup() await expect(installing).resolves.toEqual({ ok: true }) - expect(setUpdateInstallQuitting).toHaveBeenCalledTimes(2) + expect(setUpdateInstallQuitting).toHaveBeenCalledTimes(1) expect(setUpdateInstallQuitting).toHaveBeenLastCalledWith(true) expect(updater.quitAndInstall).toHaveBeenCalledWith(true, true) }) @@ -355,6 +375,137 @@ describe('installGuiUpdate', () => { message: 'quit failed' }) expect(setUpdateInstallQuitting.mock.calls).toEqual([[true], [false]]) + expect(relaunchApp).toHaveBeenCalledOnce() + expect(exitApp).toHaveBeenCalledWith(0) + }) + + it('relaunches the old application when electron-updater emits an install error', async () => { + const module = await import('./gui-updater') + const setUpdateInstallQuitting = vi.fn() + updater.quitAndInstall.mockImplementation(() => { + updater.emit('error', new Error('installer could not start')) + }) + module.initializeGuiUpdater( + () => null, + () => 'stable', + async () => undefined, + undefined, + setUpdateInstallQuitting + ) + updater.emit('update-downloaded', { version: '0.2.0', releaseDate: '2026-06-06T00:00:00.000Z' }) + + await expect(module.installGuiUpdate()).resolves.toMatchObject({ + ok: false, + code: 'install_failed', + message: 'installer could not start' + }) + expect(setUpdateInstallQuitting.mock.calls).toEqual([[true], [false]]) + expect(relaunchApp).toHaveBeenCalledOnce() + expect(exitApp).toHaveBeenCalledWith(0) + }) + + it('recovers when electron-updater reports an asynchronous NSIS launch failure', async () => { + const module = await import('./gui-updater') + const setUpdateInstallQuitting = vi.fn() + updater.quitAndInstall.mockImplementation(() => { + queueMicrotask(() => updater.emit('error', new Error('async installer launch failed'))) + }) + module.initializeGuiUpdater( + () => null, + () => 'stable', + async () => undefined, + undefined, + setUpdateInstallQuitting + ) + updater.emit('update-downloaded', { version: '0.2.0', releaseDate: '2026-06-06T00:00:00.000Z' }) + + await expect(module.installGuiUpdate()).resolves.toEqual({ ok: true }) + await Promise.resolve() + + expect(module.getGuiUpdateState()).toMatchObject({ + status: 'error', + code: 'install_failed', + message: 'async installer launch failed' + }) + expect(setUpdateInstallQuitting.mock.calls).toEqual([[true], [false]]) + expect(relaunchApp).toHaveBeenCalledOnce() + expect(exitApp).toHaveBeenCalledWith(0) + }) + + it('relaunches after a partially completed update preflight fails', async () => { + const module = await import('./gui-updater') + const setUpdateInstallQuitting = vi.fn() + module.initializeGuiUpdater( + () => null, + () => 'stable', + async () => { + throw new Error('shared manager stop timed out') + }, + undefined, + setUpdateInstallQuitting + ) + updater.emit('update-downloaded', { version: '0.2.0', releaseDate: '2026-06-06T00:00:00.000Z' }) + + await expect(module.installGuiUpdate()).resolves.toMatchObject({ + ok: false, + code: 'install_failed', + message: 'shared manager stop timed out' + }) + await Promise.resolve() + + expect(setUpdateInstallQuitting.mock.calls).toEqual([[true], [false]]) + expect(relaunchApp).toHaveBeenCalledOnce() + expect(exitApp).toHaveBeenCalledWith(0) + }) + + it('shares one install operation when the action is triggered twice', async () => { + const module = await import('./gui-updater') + let finishCleanup = (): void => { + throw new Error('cleanup resolver was not set') + } + module.initializeGuiUpdater( + () => null, + () => 'stable', + () => new Promise((resolve) => { finishCleanup = resolve }) + ) + updater.emit('update-downloaded', { version: '0.2.0', releaseDate: '2026-06-06T00:00:00.000Z' }) + + const first = module.installGuiUpdate() + const second = module.installGuiUpdate() + expect(second).toBe(first) + + await Promise.resolve() + finishCleanup() + await expect(first).resolves.toEqual({ ok: true }) + expect(updater.quitAndInstall).toHaveBeenCalledTimes(1) + }) +}) + +describe('downloadGuiUpdate recovery', () => { + it('clears stale download state so an interrupted download can be retried', async () => { + process.env.KUN_UPDATE_URL = 'https://updates.example.test/' + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })) + const module = await import('./gui-updater') + module.initializeGuiUpdater(() => null, () => 'stable') + updater.emit('update-available', { version: '0.2.0', releaseDate: '2026-06-06T00:00:00.000Z' }) + updater.downloadUpdate + .mockRejectedValueOnce(new Error('connection reset')) + .mockResolvedValueOnce(['C:\\Temp\\Kun-0.2.0.exe']) + + await expect(module.downloadGuiUpdate()).resolves.toMatchObject({ + ok: false, + code: 'download_failed' + }) + await expect(module.installGuiUpdate()).resolves.toMatchObject({ + ok: false, + code: 'install_failed', + message: 'The update has not finished downloading yet.' + }) + await expect(module.downloadGuiUpdate()).resolves.toEqual({ + ok: true, + paths: ['C:\\Temp\\Kun-0.2.0.exe'] + }) + expect(updater.downloadUpdate).toHaveBeenCalledTimes(2) }) }) diff --git a/src/main/gui-updater.ts b/src/main/gui-updater.ts index c0ac317de..c63176187 100644 --- a/src/main/gui-updater.ts +++ b/src/main/gui-updater.ts @@ -67,10 +67,20 @@ let getSelectedChannel: (() => GuiUpdateChannel | Promise) | n let getSelectedLocale: (() => AppLocale | Promise) | null = null let beforeInstallUpdate: (() => void | Promise) | null = null let beforeInstallUpdatePromise: Promise | null = null +let beforeInstallUpdatePrepared = false let setUpdateInstallQuitting: ((active: boolean) => void) | null = null let pendingVersionStateWrite: Promise | null = null let backgroundCheckTimer: NodeJS.Timeout | null = null let backgroundCheckPromise: Promise | null = null +let updateInstallQuitting = false +let installPromise: Promise | null = null +let updateInstallHandoffPending = false +let updateInstallHandoffStarted = false +let updateInstallLaunchError: Error | null = null +let updateInstallAttemptActive = false +let updateInstallRecoveryNeeded = false +let updateInstallRecoveryScheduled = false +let restoreInstallerUpdateSourceAfterFailure: (() => void) | null = null const GUI_UPDATE_SCHEDULE_FILE = 'gui-update-schedule.json' const GUI_VERSION_STATE_FILE = 'gui-version-state.json' @@ -428,11 +438,14 @@ function emitGuiUpdateState(state: GuiUpdateState): void { } function runBeforeInstallUpdate(): Promise { + if (beforeInstallUpdatePrepared) return Promise.resolve() if (!beforeInstallUpdate) return Promise.resolve() if (!beforeInstallUpdatePromise) { beforeInstallUpdatePromise = Promise.resolve() .then(() => beforeInstallUpdate?.()) - .then(() => undefined) + .then(() => { + beforeInstallUpdatePrepared = true + }) .finally(() => { beforeInstallUpdatePromise = null }) @@ -441,9 +454,52 @@ function runBeforeInstallUpdate(): Promise { } function markUpdateInstallQuitting(active: boolean): void { + if (updateInstallQuitting === active) return + updateInstallQuitting = active setUpdateInstallQuitting?.(active) } +function clearBeforeInstallUpdatePreparation(): void { + beforeInstallUpdatePrepared = false +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} + +function relaunchAfterFailedUpdateInstall(): void { + try { + app.relaunch() + app.exit(0) + } catch (error) { + console.error('[kun-gui updater] failed to relaunch after update install failure:', error) + } +} + +function resetFailedUpdateInstallState(): void { + restoreInstallerUpdateSourceAfterFailure?.() + restoreInstallerUpdateSourceAfterFailure = null + updateInstallAttemptActive = false + updateInstallHandoffPending = false + updateInstallHandoffStarted = false + updateInstallLaunchError = null + clearBeforeInstallUpdatePreparation() + markUpdateInstallQuitting(false) +} + +function scheduleFailedUpdateInstallRecovery(): void { + updateInstallRecoveryNeeded = true + if (updateInstallRecoveryScheduled) return + updateInstallRecoveryScheduled = true + queueMicrotask(() => { + updateInstallRecoveryScheduled = false + if (!updateInstallRecoveryNeeded) return + updateInstallRecoveryNeeded = false + resetFailedUpdateInstallState() + relaunchAfterFailedUpdateInstall() + }) +} + function clearBackgroundCheckTimer(): void { if (backgroundCheckTimer) { clearTimeout(backgroundCheckTimer) @@ -501,6 +557,8 @@ function configureUpdaterChannel(channel: GuiUpdateChannel, feedUrl = updateFeed configuredChannel = normalized configuredFeedUrl = feedUrl autoUpdater.allowPrerelease = normalized === 'frontier' + // Switching from frontier to stable must never install an older build. + autoUpdater.allowDowngrade = false autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl }) if (!changed) return downloaded = false @@ -650,12 +708,33 @@ export function initializeGuiUpdater( autoUpdater.on('error', (error) => { const message = error instanceof Error ? error.message : String(error) - emitGuiUpdateState({ status: 'error', info: lastInfo ?? undefined, message, code: 'unknown' }) + const installFailed = updateInstallAttemptActive + if (installFailed) { + updateInstallLaunchError = asError(error) + scheduleFailedUpdateInstallRecovery() + } + const downloadFailed = !installFailed && (downloadPromise !== null || lastState.status === 'downloading') + if (downloadFailed) { + downloaded = false + downloadPromise = null + } + emitGuiUpdateState({ + status: 'error', + info: lastInfo ?? undefined, + message, + code: installFailed ? 'install_failed' : downloadFailed ? 'download_failed' : 'unknown' + }) }) nativeAutoUpdater?.on?.('before-quit-for-update', () => { + if (updateInstallHandoffPending) { + updateInstallHandoffStarted = true + updateInstallHandoffPending = false + } markUpdateInstallQuitting(true) void runBeforeInstallUpdate().catch((error) => { + clearBeforeInstallUpdatePreparation() + markUpdateInstallQuitting(false) console.warn('[kun-gui updater] failed to stop runtimes before update quit:', error) }) }) @@ -790,13 +869,17 @@ export async function downloadGuiUpdate(channel?: GuiUpdateChannel): Promise { - downloadPromise = null + let tracked: Promise + tracked = autoUpdater.downloadUpdate().finally(() => { + if (downloadPromise === tracked) downloadPromise = null }) + downloadPromise = tracked } const paths = await downloadPromise return { ok: true, paths } } catch (e) { + downloaded = false + downloadPromise = null const message = e instanceof Error ? e.message : String(e) emitGuiUpdateState({ status: 'error', info: lastInfo ?? undefined, message, code: 'download_failed' }) return { @@ -808,7 +891,25 @@ export async function downloadGuiUpdate(channel?: GuiUpdateChannel): Promise { +export function installGuiUpdate(): Promise { + if (installPromise) return installPromise + if (updateInstallAttemptActive || updateInstallHandoffPending || updateInstallHandoffStarted) { + return Promise.resolve({ ok: true }) + } + const operation = installGuiUpdateOnce() + installPromise = operation + void operation.then( + () => { + if (installPromise === operation) installPromise = null + }, + () => { + if (installPromise === operation) installPromise = null + } + ) + return operation +} + +async function installGuiUpdateOnce(): Promise { if (DEVELOPMENT_APP_FLAVOR) { return { ok: false, @@ -829,22 +930,40 @@ export async function installGuiUpdate(): Promise { } } emitGuiUpdateState({ status: 'installing', info: lastInfo ?? undefined }) - await Promise.all([pendingVersionStateWrite, runBeforeInstallUpdate()]) - restoreInstallerUpdateSource = setWindowsInstallerUpdateSource() markUpdateInstallQuitting(true) updateInstallQuitMarked = true + await Promise.all([pendingVersionStateWrite, runBeforeInstallUpdate()]) + restoreInstallerUpdateSource = setWindowsInstallerUpdateSource() + restoreInstallerUpdateSourceAfterFailure = restoreInstallerUpdateSource // In-app updates must stay silent on Windows. The assisted NSIS UI can // surface its old-uninstaller retry dialog even though our overwrite // fallback can safely continue; silent mode applies that dialog's default // cancel action instead of asking the user to make the counter-intuitive // choice. Manually launched installers remain interactive. + updateInstallLaunchError = null + updateInstallAttemptActive = true + updateInstallHandoffPending = true + updateInstallHandoffStarted = false autoUpdater.quitAndInstall(true, true) + if (updateInstallLaunchError) throw updateInstallLaunchError return { ok: true } } catch (e) { + const relaunchRequired = updateInstallQuitMarked restoreInstallerUpdateSource() - if (updateInstallQuitMarked) markUpdateInstallQuitting(false) + if (restoreInstallerUpdateSourceAfterFailure === restoreInstallerUpdateSource) { + restoreInstallerUpdateSourceAfterFailure = null + } + updateInstallAttemptActive = false + updateInstallHandoffPending = false + updateInstallHandoffStarted = false + updateInstallLaunchError = null + if (updateInstallQuitMarked) { + clearBeforeInstallUpdatePreparation() + markUpdateInstallQuitting(false) + } const message = e instanceof Error ? e.message : String(e) emitGuiUpdateState({ status: 'error', info: lastInfo ?? undefined, message, code: 'install_failed' }) + if (relaunchRequired) scheduleFailedUpdateInstallRecovery() return { ok: false, currentVersion: app.getVersion(), diff --git a/src/main/index.ts b/src/main/index.ts index b588103c1..2290eaf4d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -211,6 +211,7 @@ import { classifyManagedRuntimeHotApplyResponse } from './runtime/kun-runtime-config-service' import { ManagedRuntimeShutdownCoordinator } from './runtime/managed-runtime-shutdown-coordinator' +import { inspectPackagedInstallHealth } from './packaged-install-health' import { registerKunExtensionProtocol, } from './extensions/extension-resource-protocol' @@ -481,6 +482,7 @@ let protectedCredentialSurface: ProtectedCredentialSurfaceController | null = nu let bindExtensionMainWindow: ((window: BrowserWindow) => void) | undefined let shutdownDesktopResourceLeases: (() => Promise) | null = null let terminalPtyController: TerminalPtyController | null = null +let activeServiceManager: ServiceManagerConnection | null = null type GuiUpdaterModule = typeof import('./gui-updater') @@ -583,6 +585,9 @@ const runtimeShutdown = new ManagedRuntimeShutdownCoordinator(async () => { if (runtimeShutdown.isUpdateInstallQuit || runtimeShutdown.isStorageRelocationQuit) { const settings = await store.load() await kunRuntimeAdapter.stopSharedAndWait(settings) + if (runtimeShutdown.isUpdateInstallQuit) { + await shutdownActiveServiceManagerForUpdate() + } if (runtimeShutdown.isStorageRelocationQuit) { const dataDir = resolveKunDataDir(resolveKunRuntimeSettings(settings)) await Promise.all([ @@ -605,6 +610,10 @@ function stopManagedRuntimes(): Promise { return runtimeShutdown.stop() } +function prepareManagedRuntimesForUpdate(): Promise { + return runtimeShutdown.prepareForUpdate() +} + async function loadGuiUpdaterModule(): Promise { if (!guiUpdaterModulePromise) { guiUpdaterModulePromise = import('./gui-updater') @@ -613,7 +622,7 @@ async function loadGuiUpdaterModule(): Promise { module.initializeGuiUpdater( () => mainWindow, async () => (await store.load()).guiUpdate.channel, - stopManagedRuntimesForQuit, + prepareManagedRuntimesForUpdate, async () => (await store.load()).locale, setUpdateInstallQuitting ) @@ -1959,7 +1968,7 @@ async function interruptStorageRelocationWork(manager: ServiceManagerConnection) throw new Error('active_writer: Timed out waiting for active Kun writes to stop.') } -async function shutdownServiceManagerForRelocation(manager: ServiceManagerConnection): Promise { +async function shutdownServiceManagerAndWait(manager: ServiceManagerConnection): Promise { await requestManagerJson(manager, '/v1/manager/shutdown', { method: 'POST', body: { instanceId: manager.discovery.instanceId }, @@ -1977,6 +1986,13 @@ async function shutdownServiceManagerForRelocation(manager: ServiceManagerConnec throw new Error('active_writer: Kun Service Manager did not exit before migration.') } +async function shutdownActiveServiceManagerForUpdate(): Promise { + const manager = activeServiceManager + if (!manager) return + await shutdownServiceManagerAndWait(manager) + if (activeServiceManager === manager) activeServiceManager = null +} + async function runStorageRelocationMaintenance(productionSettingsPath: string): Promise { const window = createStorageRelocationWindow() let relaunchScheduled = false @@ -2037,7 +2053,7 @@ async function runStorageRelocationMaintenance(productionSettingsPath: string): if (!Array.isArray(body.threads)) throw new Error('Runtime thread verification returned invalid data.') } catch (error) { if (settings) await kunRuntimeAdapter.stopSharedAndWait(settings).catch(() => undefined) - if (manager) await shutdownServiceManagerForRelocation(manager).catch(() => undefined) + if (manager) await shutdownServiceManagerAndWait(manager).catch(() => undefined) throw error } } @@ -2379,6 +2395,20 @@ app.whenReady().then(async () => { traceStartup('app.whenReady:start') if (!gotSingleInstanceLock) return + const installHealth = inspectPackagedInstallHealth({ + isPackaged: app.isPackaged, + executablePath: process.execPath, + resourcesPath: process.resourcesPath + }) + if (!installHealth.ok) { + dialog.showErrorBox( + 'Kun installation needs repair', + `The installed application is incomplete (${installHealth.missing.join(', ')}). Reinstall Kun and try again.` + ) + app.quit() + return + } + try { const cleared = await clearDevelopmentRendererHttpCache( session.defaultSession, @@ -2408,6 +2438,7 @@ app.whenReady().then(async () => { const serviceManager = await ensureKunServiceManager({ settingsPath: productionSettingsPath }) + activeServiceManager = serviceManager const sharedSettingsBackend = new ManagerRevisionedDocumentClient(serviceManager, 'settings') const sharedClientStateDocument = new ManagerRevisionedDocumentClient(serviceManager, 'client-state') ipcMain.handle('shared-client-state:get', async () => { @@ -2870,7 +2901,8 @@ app.whenReady().then(async () => { await interruptStorageRelocationWork(serviceManager) runtimeShutdown.setStorageRelocationQuit(true) await runtimeShutdown.stopForQuit() - await shutdownServiceManagerForRelocation(serviceManager) + await shutdownServiceManagerAndWait(serviceManager) + if (activeServiceManager === serviceManager) activeServiceManager = null mainWindow?.destroy() app.relaunch() app.exit(0) diff --git a/src/main/packaged-install-health.test.ts b/src/main/packaged-install-health.test.ts new file mode 100644 index 000000000..730957d78 --- /dev/null +++ b/src/main/packaged-install-health.test.ts @@ -0,0 +1,54 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { inspectPackagedInstallHealth } from './packaged-install-health' + +describe('inspectPackagedInstallHealth', () => { + let directory = '' + + afterEach(async () => { + if (directory) await rm(directory, { recursive: true, force: true }) + directory = '' + }) + + async function createHealthyPayload(): Promise<{ executablePath: string; resourcesPath: string }> { + directory = await mkdtemp(join(tmpdir(), 'kun-packaged-install-health-')) + const resourcesPath = join(directory, 'resources') + const executablePath = join(directory, 'Kun.exe') + await mkdir(join(resourcesPath, 'app.asar.unpacked', 'kun', 'dist', 'cli'), { recursive: true }) + await mkdir(join(resourcesPath, 'app.asar.unpacked', 'kun', 'dist', 'manager'), { recursive: true }) + await Promise.all([ + writeFile(executablePath, 'exe'), + writeFile(join(resourcesPath, 'app.asar'), 'asar'), + writeFile(join(resourcesPath, 'app.asar.unpacked', 'kun', 'dist', 'cli', 'serve-entry.js'), 'serve'), + writeFile(join(resourcesPath, 'app.asar.unpacked', 'kun', 'dist', 'manager', 'manager-entry.js'), 'manager') + ]) + return { executablePath, resourcesPath } + } + + it('accepts a complete packaged payload', async () => { + const input = await createHealthyPayload() + + expect(inspectPackagedInstallHealth({ isPackaged: true, ...input })).toEqual({ ok: true }) + }) + + it('reports every missing or empty runtime component', async () => { + const input = await createHealthyPayload() + await writeFile(join(input.resourcesPath, 'app.asar'), '') + await rm(join(input.resourcesPath, 'app.asar.unpacked', 'kun', 'dist', 'manager', 'manager-entry.js')) + + expect(inspectPackagedInstallHealth({ isPackaged: true, ...input })).toEqual({ + ok: false, + missing: ['resources/app.asar', 'Kun service manager entry'] + }) + }) + + it('does not block source and development runs', () => { + expect(inspectPackagedInstallHealth({ + isPackaged: false, + executablePath: 'missing.exe', + resourcesPath: 'missing-resources' + })).toEqual({ ok: true }) + }) +}) diff --git a/src/main/packaged-install-health.ts b/src/main/packaged-install-health.ts new file mode 100644 index 000000000..0fa0747fc --- /dev/null +++ b/src/main/packaged-install-health.ts @@ -0,0 +1,45 @@ +import { statSync } from 'node:fs' +import { join } from 'node:path' + +export type PackagedInstallHealth = + | { ok: true } + | { ok: false; missing: string[] } + +type PackagedInstallHealthInput = { + isPackaged: boolean + executablePath: string + resourcesPath: string +} + +function isNonEmptyFile(path: string): boolean { + try { + const stat = statSync(path) + return stat.isFile() && stat.size > 0 + } catch { + return false + } +} + +/** + * Detect the subset of interrupted-install failures where Electron can still + * start but the unpacked Kun runtime is incomplete. A missing app.asar cannot + * reach this code, so the installer performs the same post-install check. + */ +export function inspectPackagedInstallHealth(input: PackagedInstallHealthInput): PackagedInstallHealth { + if (!input.isPackaged) return { ok: true } + + const required = [ + { label: 'application executable', path: input.executablePath }, + { label: 'resources/app.asar', path: join(input.resourcesPath, 'app.asar') }, + { + label: 'Kun runtime entry', + path: join(input.resourcesPath, 'app.asar.unpacked', 'kun', 'dist', 'cli', 'serve-entry.js') + }, + { + label: 'Kun service manager entry', + path: join(input.resourcesPath, 'app.asar.unpacked', 'kun', 'dist', 'manager', 'manager-entry.js') + } + ] + const missing = required.filter((entry) => !isNonEmptyFile(entry.path)).map((entry) => entry.label) + return missing.length === 0 ? { ok: true } : { ok: false, missing } +} diff --git a/src/main/runtime/managed-runtime-shutdown-coordinator.test.ts b/src/main/runtime/managed-runtime-shutdown-coordinator.test.ts index fb65f4148..ece479179 100644 --- a/src/main/runtime/managed-runtime-shutdown-coordinator.test.ts +++ b/src/main/runtime/managed-runtime-shutdown-coordinator.test.ts @@ -35,6 +35,56 @@ describe('ManagedRuntimeShutdownCoordinator', () => { expect(coordinator.isQuitInProgress).toBe(false) }) + it('prepares an update without making a failed installer attempt terminal', async () => { + const stop = vi.fn(async () => undefined) + const coordinator = new ManagedRuntimeShutdownCoordinator(stop) + + await coordinator.prepareForUpdate() + + expect(coordinator.isUpdateInstallQuit).toBe(true) + expect(coordinator.isStoppedForQuit).toBe(false) + expect(stop).toHaveBeenCalledOnce() + + await coordinator.stopForQuit() + expect(stop).toHaveBeenCalledOnce() + expect(coordinator.isStoppedForQuit).toBe(true) + }) + + it('clears update intent and remains retryable when update preparation fails', async () => { + const stop = vi.fn() + .mockRejectedValueOnce(new Error('stop failed')) + .mockResolvedValueOnce(undefined) + const coordinator = new ManagedRuntimeShutdownCoordinator(stop) + + await expect(coordinator.prepareForUpdate()).rejects.toThrow('stop failed') + expect(coordinator.isUpdateInstallQuit).toBe(false) + expect(coordinator.isStoppedForQuit).toBe(false) + + await coordinator.prepareForUpdate() + expect(stop).toHaveBeenCalledTimes(2) + }) + + it('repeats an in-flight non-update stop before declaring update preparation complete', async () => { + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const observedUpdateIntent: boolean[] = [] + let coordinator!: ManagedRuntimeShutdownCoordinator + const stop = vi.fn(async () => { + observedUpdateIntent.push(coordinator.isUpdateInstallQuit) + if (observedUpdateIntent.length === 1) await gate + }) + coordinator = new ManagedRuntimeShutdownCoordinator(stop) + + const normalStop = coordinator.stop() + const updatePreparation = coordinator.prepareForUpdate() + release() + await Promise.all([normalStop, updatePreparation]) + + expect(observedUpdateIntent).toEqual([false, true]) + await coordinator.stopForQuit() + expect(stop).toHaveBeenCalledTimes(2) + }) + it('treats storage relocation as terminal quit intent', () => { const coordinator = new ManagedRuntimeShutdownCoordinator(async () => undefined) coordinator.setStorageRelocationQuit(true) diff --git a/src/main/runtime/managed-runtime-shutdown-coordinator.ts b/src/main/runtime/managed-runtime-shutdown-coordinator.ts index 5a9860596..771d720aa 100644 --- a/src/main/runtime/managed-runtime-shutdown-coordinator.ts +++ b/src/main/runtime/managed-runtime-shutdown-coordinator.ts @@ -2,9 +2,11 @@ export class ManagedRuntimeShutdownCoordinator { private quitRequested = false private updateInstallQuit = false + private updateInstallPrepared = false private storageRelocationQuit = false private stoppedForQuit = false private stopPromise: Promise | null = null + private stopIncludesUpdateIntent = false constructor(private readonly stopManagedRuntimes: () => Promise) {} @@ -29,7 +31,9 @@ export class ManagedRuntimeShutdownCoordinator { } setUpdateInstallQuit(active: boolean): void { + if (this.updateInstallQuit === active) return this.updateInstallQuit = active + if (!active) this.updateInstallPrepared = false } get isStorageRelocationQuit(): boolean { @@ -42,19 +46,48 @@ export class ManagedRuntimeShutdownCoordinator { stop(): Promise { if (this.stopPromise) return this.stopPromise + const includesUpdateIntent = this.updateInstallQuit let tracked: Promise tracked = this.stopManagedRuntimes().finally(() => { - if (this.stopPromise === tracked) this.stopPromise = null + if (this.stopPromise === tracked) { + this.stopPromise = null + this.stopIncludesUpdateIntent = false + } }) this.stopPromise = tracked + this.stopIncludesUpdateIntent = includesUpdateIntent return tracked } + /** + * Stop managed runtimes before an updater asks Electron to quit. Unlike a + * normal quit this remains retryable: an updater can reject synchronously + * after preflight, and the GUI must not be left in a terminal shutdown state. + */ + async prepareForUpdate(): Promise { + this.setUpdateInstallQuit(true) + if (this.updateInstallPrepared) return + try { + // A normal window-close stop may already be in flight. It started + // without update intent, so wait for it and perform a second, explicit + // update stop rather than treating it as sufficient for file unlocks. + const activeStop = this.stopPromise + if (activeStop && !this.stopIncludesUpdateIntent) await activeStop + await this.stop() + this.updateInstallPrepared = true + } catch (error) { + this.setUpdateInstallQuit(false) + throw error + } + } + async stopForQuit(): Promise { this.requestQuit() if (this.stoppedForQuit) return try { - await this.stop() + // A successful update preflight already stopped the same resources. Do + // not run it twice when Electron subsequently emits `before-quit`. + if (!this.updateInstallPrepared) await this.stop() } finally { // Quit remains terminal even when one adapter reports a stop error: the // supervisor must never spawn a replacement child after this point. diff --git a/src/main/windows-installer-migration.test.ts b/src/main/windows-installer-migration.test.ts index e3bf8499e..6a1f4ebb1 100644 --- a/src/main/windows-installer-migration.test.ts +++ b/src/main/windows-installer-migration.test.ts @@ -26,7 +26,7 @@ function makeTempRoot(): string { } function runHelper(input: { - action: 'ResolvePath' | 'ResolveSource' | 'ResolveUpdateScope' | 'ResolveUninstaller' | 'Recover' | 'Prepare' | 'FallbackCleanup' | 'Restore' + action: 'ResolvePath' | 'ResolveSource' | 'ResolveUpdateScope' | 'ResolveUninstaller' | 'Recover' | 'Prepare' | 'FallbackCleanup' | 'Restore' | 'ValidatePayload' source?: string secondary?: string currentUserSource?: string @@ -114,6 +114,22 @@ function readJournal(path: string): { Records: Array<{ Stash: string }> } { } } +function writePackagedInstallPayload(root: string, executable = 'Kun.exe') { + writeFileSync(join(root, executable), 'application executable') + const resources = join(root, 'resources') + mkdirSync(join(resources, 'app.asar.unpacked', 'kun', 'dist', 'cli'), { recursive: true }) + writeFileSync(join(resources, 'app.asar'), 'packaged application') + writeFileSync( + join(resources, 'app.asar.unpacked', 'kun', 'dist', 'cli', 'serve-entry.js'), + 'runtime entry' + ) + mkdirSync(join(resources, 'app.asar.unpacked', 'kun', 'dist', 'manager'), { recursive: true }) + writeFileSync( + join(resources, 'app.asar.unpacked', 'kun', 'dist', 'manager', 'manager-entry.js'), + 'service manager entry' + ) +} + afterEach(() => { while (tempRoots.length > 0) { const root = tempRoots.pop() @@ -122,6 +138,52 @@ afterEach(() => { }) windowsOnly('Windows installer migration helper', () => { + it('validates the installed application payload before PATH is updated', () => { + const target = join(makeTempRoot(), 'Kun') + mkdirSync(target, { recursive: true }) + writePackagedInstallPayload(target) + + const result = runHelper({ action: 'ValidatePayload', target }) + + expect(result.status, processError(result)).toBe(0) + }) + + it.each([ + ['application executable', (target: string) => join(target, 'Kun.exe')], + ['resources\\app.asar', (target: string) => join(target, 'resources', 'app.asar')], + [ + 'unpacked Kun runtime entry', + (target: string) => join(target, 'resources', 'app.asar.unpacked', 'kun', 'dist', 'cli', 'serve-entry.js') + ], + [ + 'unpacked Kun service manager entry', + (target: string) => join(target, 'resources', 'app.asar.unpacked', 'kun', 'dist', 'manager', 'manager-entry.js') + ] + ])('rejects an incomplete installed payload missing %s', (label, missingPath) => { + const target = join(makeTempRoot(), 'Kun') + mkdirSync(target, { recursive: true }) + writePackagedInstallPayload(target) + rmSync(missingPath(target)) + + const result = runHelper({ action: 'ValidatePayload', target }) + + expect(result.status).not.toBe(0) + expect(processError(result)).toContain('payload is missing') + expect(processError(result)).toContain(label) + }) + + it('rejects an empty installed payload file', () => { + const target = join(makeTempRoot(), 'Kun') + mkdirSync(target, { recursive: true }) + writePackagedInstallPayload(target) + writeFileSync(join(target, 'resources', 'app.asar'), '') + + const result = runHelper({ action: 'ValidatePayload', target }) + + expect(result.status).not.toBe(0) + expect(processError(result)).toContain('payload is empty for resources\\app.asar') + }) + it.each([ ['C:\\Users\\me\\AppData\\Local\\Programs\\DeepSeek GUI', '', 'C:\\Users\\me\\AppData\\Local\\Programs\\Kun'], ['D:\\Apps\\deepseek-gui', '', 'D:\\Apps\\Kun'], diff --git a/src/renderer/src/components/Workbench.tsx b/src/renderer/src/components/Workbench.tsx index 8d8f76d75..64981878f 100644 --- a/src/renderer/src/components/Workbench.tsx +++ b/src/renderer/src/components/Workbench.tsx @@ -832,7 +832,7 @@ export function Workbench(): ReactElement { startNewSddAssistantConversation } = useWorkbenchSddTurnController({ activeGuiPlan, attachmentUploadEnabled, blocks, busy, composerAttachments, composerMode, - composerModelGroups, composerReasoningEffort, input, resolvedWriteAssistantProviderId, + composerModelGroups, composerReasoningEffort, composerFastMode, input, resolvedWriteAssistantProviderId, runtimeConnection, runtimeInfo, selectedModelSupportsImageInput, sendMessage, sendPlanTurn, setAttachmentUploadError, setComposerMode, setError, setInput, setWriteAssistantModel, writeAssistantModel, clearComposerAttachments, ensureSddAssistantThreadForDraft, getAttachmentScope, @@ -1109,7 +1109,9 @@ export function Workbench(): ReactElement { composerModel: writeAssistantModel, composerProviderId: resolvedWriteAssistantProviderId, composerPickList: writeAssistantPickList, + composerFastMode, setComposerModel: setWriteAssistantModel, + setComposerFastMode, onApplyFramework: applySddFramework, onNewConversation: () => { if (!activeSddDraft) return diff --git a/src/renderer/src/components/graph/GraphModePanel.test.ts b/src/renderer/src/components/graph/GraphModePanel.test.ts index e6fcc29d3..c0b75152c 100644 --- a/src/renderer/src/components/graph/GraphModePanel.test.ts +++ b/src/renderer/src/components/graph/GraphModePanel.test.ts @@ -1,6 +1,11 @@ -import { type ReactElement } from 'react' +import { createElement, type ReactElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it } from 'vitest' +import { + act, + create as createRenderer, + type ReactTestRenderer +} from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { GraphAttempt, GraphChildRuntime, @@ -12,9 +17,12 @@ import { criticalPathNodeIds, filterGraphElementsByPhases, graphElements, + GRAPH_DASHBOARD_SNAPSHOT_REFRESH_INTERVAL_MS, + graphDashboardNeedsSnapshotRefresh, plannedAssignmentLabel, runProgress, - selectGraphPlanningDraft + selectGraphPlanningDraft, + useGraphDashboardSnapshotRefresh } from './GraphModePanel' import { reconcileInteractiveGraphNodes } from './graph-canvas-state' import { clampGraphInspectorWidth } from './graph-workspace-layout' @@ -118,7 +126,104 @@ function planningDraft( } } +function SnapshotRefreshHarness({ + active, + shouldRefresh, + refreshThread +}: { + active: boolean + shouldRefresh: boolean + refreshThread: (threadId: string, options?: { silent?: boolean }) => Promise +}): null { + useGraphDashboardSnapshotRefresh({ + active, + threadId: 'thread_1', + shouldRefresh, + refreshThread + }) + return null +} + describe('Graph Mode panel projection', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('reconciles nonterminal Graph work and an active source turn even before a run is visible', () => { + const liveRun = graphRun([node('work', 'phase_1')], []) + const terminalRun = { ...liveRun, status: 'completed' as const } + const pausedRun = { ...liveRun, status: 'paused' as const } + + expect(graphDashboardNeedsSnapshotRefresh([liveRun], [], false)).toBe(true) + expect(graphDashboardNeedsSnapshotRefresh([terminalRun], [], false)).toBe(false) + expect(graphDashboardNeedsSnapshotRefresh([pausedRun], [], false)).toBe(false) + expect(graphDashboardNeedsSnapshotRefresh([], [planningDraft('draft_1', 'planning')], false)) + .toBe(true) + expect(graphDashboardNeedsSnapshotRefresh([], [], true)).toBe(true) + }) + + it('polls durable Graph snapshots without overlapping requests and stops when hidden', async () => { + vi.useFakeTimers() + const resolveRefreshes: Array<() => void> = [] + const refreshThread = vi.fn(() => new Promise((resolve) => { + resolveRefreshes.push(resolve) + })) + let renderer!: ReactTestRenderer + + await act(async () => { + renderer = createRenderer(createElement(SnapshotRefreshHarness, { + active: true, + shouldRefresh: true, + refreshThread + })) + }) + + await act(async () => { + vi.advanceTimersByTime(GRAPH_DASHBOARD_SNAPSHOT_REFRESH_INTERVAL_MS) + await Promise.resolve() + }) + expect(refreshThread).toHaveBeenCalledTimes(1) + expect(refreshThread).toHaveBeenLastCalledWith('thread_1', { silent: true }) + + await act(async () => { + vi.advanceTimersByTime(GRAPH_DASHBOARD_SNAPSHOT_REFRESH_INTERVAL_MS * 3) + await Promise.resolve() + }) + expect(refreshThread).toHaveBeenCalledTimes(1) + + await act(async () => { + resolveRefreshes.shift()?.() + await Promise.resolve() + await Promise.resolve() + }) + await act(async () => { + vi.advanceTimersByTime(GRAPH_DASHBOARD_SNAPSHOT_REFRESH_INTERVAL_MS) + await Promise.resolve() + }) + expect(refreshThread).toHaveBeenCalledTimes(2) + + await act(async () => { + resolveRefreshes.shift()?.() + await Promise.resolve() + await Promise.resolve() + }) + + await act(async () => { + renderer.update(createElement(SnapshotRefreshHarness, { + active: false, + shouldRefresh: true, + refreshThread + })) + }) + await act(async () => { + vi.advanceTimersByTime(GRAPH_DASHBOARD_SNAPSHOT_REFRESH_INTERVAL_MS * 2) + await Promise.resolve() + }) + expect(refreshThread).toHaveBeenCalledTimes(2) + + await act(async () => renderer.unmount()) + }) + it('never lets a terminal host error hide the current run or create a second error surface', () => { const hostError = planningDraft('draft_failed', 'host_error') const correction = planningDraft('draft_active', 'needs_correction') diff --git a/src/renderer/src/components/graph/GraphModePanel.tsx b/src/renderer/src/components/graph/GraphModePanel.tsx index 33cc9eff9..114d85eee 100644 --- a/src/renderer/src/components/graph/GraphModePanel.tsx +++ b/src/renderer/src/components/graph/GraphModePanel.tsx @@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next' import { useChatStore } from '../../store/chat-store' import { openGraphChildThread } from '../../graph/graph-child-navigation' import { useGraphStore } from '../../graph/graph-store' -import type { GraphPlanningDraftView } from '../../graph/graph-types' +import type { GraphPlanningDraftView, GraphRun } from '../../graph/graph-types' import { GraphAgentsView } from './GraphAgentsView' import { GraphLearningView } from './GraphLearningView' import { GraphPlanningCard } from './GraphPlanningCard' @@ -33,6 +33,67 @@ export { type View = 'run' | 'agents' | 'learning' +export const GRAPH_DASHBOARD_SNAPSHOT_REFRESH_INTERVAL_MS = 5_000 + +const liveGraphRunStatuses = new Set([ + 'draft', + 'validating', + 'ready', + 'running', + 'pausing', + 'awaiting_supervision', + 'completing' +]) + +const livePlanningDraftStatuses = new Set([ + 'planning', + 'validating', + 'repairing', + 'committing' +]) + +export function graphDashboardNeedsSnapshotRefresh( + runs: readonly Pick[], + drafts: readonly Pick[], + sourceGraphTurnActive: boolean +): boolean { + return sourceGraphTurnActive || + runs.some((run) => liveGraphRunStatuses.has(run.status)) || + drafts.some((draft) => livePlanningDraftStatuses.has(draft.draft.status)) +} + +export function useGraphDashboardSnapshotRefresh({ + active, + threadId, + shouldRefresh, + refreshThread +}: { + active: boolean + threadId: string | null + shouldRefresh: boolean + refreshThread: (threadId: string, options?: { silent?: boolean }) => Promise +}): void { + useEffect(() => { + if (!active || !threadId || !shouldRefresh) return + let disposed = false + let timeout: ReturnType | null = null + const scheduleRefresh = (): void => { + timeout = globalThis.setTimeout(() => { + void refreshThread(threadId, { silent: true }) + .catch(() => undefined) + .finally(() => { + if (!disposed) scheduleRefresh() + }) + }, GRAPH_DASHBOARD_SNAPSHOT_REFRESH_INTERVAL_MS) + } + scheduleRefresh() + return () => { + disposed = true + if (timeout !== null) globalThis.clearTimeout(timeout) + } + }, [active, refreshThread, shouldRefresh, threadId]) +} + export function selectGraphPlanningDraft( drafts: readonly GraphPlanningDraftView[], hasRun: boolean @@ -48,15 +109,19 @@ export function selectGraphPlanningDraft( export function GraphModePanel({ className = '', - onCollapse + onCollapse, + active = true }: { className?: string onCollapse?: () => void + active?: boolean }): ReactElement { const { t } = useTranslation('common') const reducedMotion = usePrefersReducedMotion() const activeThreadId = useChatStore((state) => state.activeThreadId) const workspaceRoot = useChatStore((state) => state.workspaceRoot) + const threadBusy = useChatStore((state) => state.busy) + const currentTurnOrchestration = useChatStore((state) => state.currentTurnOrchestration) const [view, setView] = useState('run') const [steering, setSteering] = useState('') const { @@ -110,10 +175,25 @@ export function GraphModePanel({ const graphThreadId = childReturnTarget?.childThreadId === activeThreadId ? childReturnTarget.parentThreadId : activeThreadId + const sourceGraphTurnActive = graphThreadId === activeThreadId && + threadBusy && currentTurnOrchestration === 'graph' + const shouldRefreshSnapshot = graphDashboardNeedsSnapshotRefresh( + runs, + drafts, + sourceGraphTurnActive + ) useEffect(() => { + if (!active) return void refreshThread(graphThreadId) - }, [graphThreadId, refreshThread]) + }, [active, graphThreadId, refreshThread]) + + useGraphDashboardSnapshotRefresh({ + active, + threadId: graphThreadId, + shouldRefresh: shouldRefreshSnapshot, + refreshThread + }) useEffect(() => { void refreshProject(workspaceRoot) diff --git a/src/renderer/src/components/sdd/SddAssistantPanel.test.ts b/src/renderer/src/components/sdd/SddAssistantPanel.test.ts new file mode 100644 index 000000000..5ebcdd46d --- /dev/null +++ b/src/renderer/src/components/sdd/SddAssistantPanel.test.ts @@ -0,0 +1,83 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it } from 'vitest' +import i18n from '../../i18n' +import { useChatStore } from '../../store/chat-store' +import { SddAssistantPanel } from './SddAssistantPanel' + +describe('SddAssistantPanel', () => { + beforeEach(async () => { + await i18n.changeLanguage('en') + useChatStore.setState({ + activeThreadId: 'thr_sdd', + activeThreadGoal: null, + route: 'chat', + workspaceRoot: '/workspace', + threads: [] + }) + }) + + it('shows separate reasoning and Fast controls for eligible Codex models', () => { + const html = renderToStaticMarkup(createElement(SddAssistantPanel, { + draft: { + id: 'draft-1', + workspaceRoot: '/workspace', + relativePath: '.kunsdd/requirements/draft-1/requirement.md', + createdAt: '2026-08-03T00:00:00.000Z', + updatedAt: '2026-08-03T00:00:00.000Z' + }, + input: '', + setInput: () => undefined, + mode: 'agent', + setMode: () => undefined, + busy: false, + runtimeConnection: 'ready', + activeThreadId: 'thr_sdd', + blocks: [], + liveReasoning: '', + liveAssistant: '', + composerModel: 'gpt-5.6-sol', + composerProviderId: 'codex', + composerPickList: ['gpt-5.6-sol'], + composerModelGroups: [{ + providerId: 'codex', + presetSource: 'codex', + label: 'Codex', + modelIds: ['gpt-5.6-sol'], + modelProfiles: { + 'gpt-5.6-sol': { + inputModalities: ['text'], + outputModalities: ['text'], + supportsToolCalling: true, + messageParts: ['text'], + reasoning: { + supportedEfforts: ['off', 'low', 'medium', 'high', 'max'], + defaultEffort: 'high', + requestProtocol: 'openai-responses' + }, + serviceTiers: ['priority'] + } + } + }], + composerReasoningEffort: 'high', + composerFastMode: true, + setComposerModel: () => undefined, + setComposerReasoningEffort: () => undefined, + setComposerFastMode: () => undefined, + queuedMessages: [], + removeQueuedMessage: () => undefined, + guideQueuedMessage: () => undefined, + onSend: () => undefined, + onInterrupt: () => undefined, + onRetryConnection: () => undefined, + onOpenSettings: () => undefined, + onNewConversation: () => undefined, + onApplyFramework: () => undefined, + onCollapse: () => undefined + })) + + expect(html).toContain('aria-label="Model"') + expect(html).toContain('aria-label="Reasoning: High"') + expect(html).toContain('aria-label="Fast mode on"') + }) +}) diff --git a/src/renderer/src/components/sdd/SddAssistantPanel.tsx b/src/renderer/src/components/sdd/SddAssistantPanel.tsx index 4b11ce1bf..e830613b9 100644 --- a/src/renderer/src/components/sdd/SddAssistantPanel.tsx +++ b/src/renderer/src/components/sdd/SddAssistantPanel.tsx @@ -76,8 +76,10 @@ type Props = { composerPickList: string[] composerModelGroups?: ModelProviderModelGroup[] composerReasoningEffort: ComposerReasoningEffort + composerFastMode: boolean setComposerModel: (modelId: string, providerId?: string) => void setComposerReasoningEffort: (effort: ComposerReasoningEffort) => void + setComposerFastMode: (enabled: boolean) => void queuedMessages: QueuedUserMessage[] removeQueuedMessage: (id: string) => void guideQueuedMessage: (id: string) => void | Promise @@ -116,8 +118,10 @@ export function SddAssistantPanel({ composerPickList, composerModelGroups = [], composerReasoningEffort, + composerFastMode, setComposerModel, setComposerReasoningEffort, + setComposerFastMode, queuedMessages, removeQueuedMessage, guideQueuedMessage, @@ -273,9 +277,13 @@ export function SddAssistantPanel({ composerPickList={composerPickList} composerModelGroups={composerModelGroups} composerReasoningEffort={composerReasoningEffort} + composerFastMode={composerFastMode} onComposerModelChange={setComposerModel} onComposerReasoningEffortChange={setComposerReasoningEffort} + onComposerFastModeChange={setComposerFastMode} modelPickerMode="combobox" + modelControlVariant="split" + showProviderInModelLabel queuedMessages={queuedMessages} onRemoveQueuedMessage={removeQueuedMessage} onGuideQueuedMessage={guideQueuedMessage} diff --git a/src/renderer/src/components/sdd/SddAssistantPanel.user-input.test.ts b/src/renderer/src/components/sdd/SddAssistantPanel.user-input.test.ts index 0b3e05893..69c160bd7 100644 --- a/src/renderer/src/components/sdd/SddAssistantPanel.user-input.test.ts +++ b/src/renderer/src/components/sdd/SddAssistantPanel.user-input.test.ts @@ -92,8 +92,10 @@ describe('SddAssistantPanel structured user input', () => { composerProviderId: 'codex', composerPickList: ['gpt-5.6'], composerReasoningEffort: 'low', + composerFastMode: false, setComposerModel: vi.fn(), setComposerReasoningEffort: vi.fn(), + setComposerFastMode: vi.fn(), queuedMessages: [], removeQueuedMessage: vi.fn(), guideQueuedMessage: vi.fn(), diff --git a/src/renderer/src/components/workbench/WorkbenchRightPanel.tsx b/src/renderer/src/components/workbench/WorkbenchRightPanel.tsx index 1d0205381..1c00a969b 100644 --- a/src/renderer/src/components/workbench/WorkbenchRightPanel.tsx +++ b/src/renderer/src/components/workbench/WorkbenchRightPanel.tsx @@ -200,7 +200,11 @@ export function WorkbenchRightPanel({ ) : rightPanelMode === BUILTIN_RIGHT_PANEL_IDS.mcpSkills ? ( ) : rightPanelMode === BUILTIN_RIGHT_PANEL_IDS.graph ? ( - + ) : rightPanelMode && isExtensionContributionId(rightPanelMode) && extensionView?.id === rightPanelMode ? ( ) : ( @@ -341,7 +345,13 @@ function CodeRightPanelWorkspace({ return } if (id === BUILTIN_RIGHT_PANEL_IDS.graph) { - return + return ( + + ) } if (id === BUILTIN_RIGHT_PANEL_IDS.agentPerspective) { return ( diff --git a/src/renderer/src/components/workbench/useWorkbenchRightPanelElement.tsx b/src/renderer/src/components/workbench/useWorkbenchRightPanelElement.tsx index 15475508c..ed5eb8b4d 100644 --- a/src/renderer/src/components/workbench/useWorkbenchRightPanelElement.tsx +++ b/src/renderer/src/components/workbench/useWorkbenchRightPanelElement.tsx @@ -79,7 +79,9 @@ type WorkbenchRightPanelElementOptions = Pick< | 'composerModel' | 'composerProviderId' | 'composerPickList' + | 'composerFastMode' | 'setComposerModel' + | 'setComposerFastMode' | 'onApplyFramework' | 'onNewConversation' > diff --git a/src/renderer/src/components/workbench/useWorkbenchSddTurnController.test.ts b/src/renderer/src/components/workbench/useWorkbenchSddTurnController.test.ts index 4ec3c1c07..2f0900614 100644 --- a/src/renderer/src/components/workbench/useWorkbenchSddTurnController.test.ts +++ b/src/renderer/src/components/workbench/useWorkbenchSddTurnController.test.ts @@ -6,11 +6,28 @@ describe('SDD assistant model selection', () => { expect(buildSddAssistantModelOverrides({ model: ' gpt-5.6-sol ', providerId: ' codex ', - reasoningEffort: 'max' + reasoningEffort: 'max', + fastMode: true, + modelGroups: [{ + providerId: 'codex', + presetSource: 'codex', + label: 'Codex', + modelIds: ['gpt-5.6-sol'], + modelProfiles: { + 'gpt-5.6-sol': { + inputModalities: ['text'], + outputModalities: ['text'], + supportsToolCalling: true, + messageParts: ['text'], + serviceTiers: ['priority'] + } + } + }] })).toEqual({ model: 'gpt-5.6-sol', providerId: 'codex', - reasoningEffort: 'max' + reasoningEffort: 'max', + serviceTier: 'priority' }) }) @@ -18,7 +35,9 @@ describe('SDD assistant model selection', () => { expect(buildSddAssistantModelOverrides({ model: ' ', providerId: '', - reasoningEffort: 'auto' + reasoningEffort: 'auto', + fastMode: false, + modelGroups: [] })).toEqual({ reasoningEffort: 'auto' }) diff --git a/src/renderer/src/components/workbench/useWorkbenchSddTurnController.ts b/src/renderer/src/components/workbench/useWorkbenchSddTurnController.ts index 789c53600..d2b92ee94 100644 --- a/src/renderer/src/components/workbench/useWorkbenchSddTurnController.ts +++ b/src/renderer/src/components/workbench/useWorkbenchSddTurnController.ts @@ -18,6 +18,7 @@ import { composerReasoningEffortRequestValue, type ComposerReasoningEffort } from '../chat/FloatingComposerModelPicker' +import { serviceTierForComposerSelection } from '../chat/composer-fast-mode' import { firstVisionCapableComposerModel } from './useWorkbenchComposerCapabilities' @@ -59,6 +60,7 @@ type PlanTurnOverrides = Pick< | 'model' | 'providerId' | 'reasoningEffort' + | 'serviceTier' > & { workspaceRoot?: string } @@ -72,6 +74,7 @@ type UseWorkbenchSddTurnControllerParams = { composerMode: 'plan' | 'agent' composerModelGroups: ModelProviderModelGroup[] composerReasoningEffort: ComposerReasoningEffort + composerFastMode: boolean input: string resolvedWriteAssistantProviderId: string runtimeConnection: RuntimeConnectionStatus @@ -149,14 +152,23 @@ export function buildSddAssistantModelOverrides(input: { model: string providerId: string reasoningEffort: ComposerReasoningEffort -}): Pick { + fastMode: boolean + modelGroups: readonly ModelProviderModelGroup[] +}): Pick { const model = input.model.trim() const providerId = input.providerId.trim() const reasoningEffort = composerReasoningEffortRequestValue(input.reasoningEffort) + const serviceTier = serviceTierForComposerSelection( + input.fastMode, + input.modelGroups, + model, + providerId + ) return { ...(model ? { model } : {}), ...(providerId ? { providerId } : {}), - ...(reasoningEffort ? { reasoningEffort } : {}) + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(serviceTier ? { serviceTier } : {}) } } @@ -169,6 +181,7 @@ export function useWorkbenchSddTurnController({ composerMode, composerModelGroups, composerReasoningEffort, + composerFastMode, input, resolvedWriteAssistantProviderId, runtimeConnection, @@ -309,7 +322,9 @@ export function useWorkbenchSddTurnController({ const modelOverrides = buildSddAssistantModelOverrides({ model: writeAssistantModel, providerId: resolvedWriteAssistantProviderId, - reasoningEffort: composerReasoningEffort + reasoningEffort: composerReasoningEffort, + fastMode: composerFastMode, + modelGroups: composerModelGroups }) const sent = await sendMessage(prompt, composerMode === 'plan' ? 'plan' : 'agent', { displayText: v || (documentAttachments.length > 0 @@ -334,6 +349,8 @@ export function useWorkbenchSddTurnController({ clearComposerAttachments, composerAttachments, composerMode, + composerFastMode, + composerModelGroups, composerReasoningEffort, ensureSddAssistantThreadForDraft, getAttachmentScope, @@ -417,8 +434,13 @@ export function useWorkbenchSddTurnController({ assistantSelection.assistantProviderId.trim() || providerIdForComposerModel(composerModelGroups, model) const sent = await sendMessage(payload.prompt, 'agent', { displayText: payload.displayText, - ...(model ? { model } : {}), - ...(providerId ? { providerId } : {}), + ...buildSddAssistantModelOverrides({ + model, + providerId, + reasoningEffort: composerReasoningEffort, + fastMode: composerFastMode, + modelGroups: composerModelGroups + }), ...(attachmentIds.length ? { attachmentIds } : {}) }) if (sent && showSddAssistantThreadInSidebar(threadId)) { @@ -427,6 +449,8 @@ export function useWorkbenchSddTurnController({ return sent }, [ composerModelGroups, + composerFastMode, + composerReasoningEffort, ensureSddAssistantThreadForDraft, firstVisionCapableModel, openSddAssistantPanel, @@ -539,7 +563,9 @@ export function useWorkbenchSddTurnController({ const modelOverrides = buildSddAssistantModelOverrides({ model: writeAssistantModel, providerId: resolvedWriteAssistantProviderId, - reasoningEffort: composerReasoningEffort + reasoningEffort: composerReasoningEffort, + fastMode: composerFastMode, + modelGroups: composerModelGroups }) const sent = await sendPlanTurn(prompt, { displayText: t('sddGeneratePlanAction'), @@ -579,6 +605,8 @@ export function useWorkbenchSddTurnController({ } }, [ blocks, + composerFastMode, + composerModelGroups, composerReasoningEffort, ensureSddAssistantThreadForDraft, resolvedWriteAssistantProviderId, diff --git a/src/renderer/src/graph/graph-store.test.ts b/src/renderer/src/graph/graph-store.test.ts index 6495fc8f8..aae71d8f0 100644 --- a/src/renderer/src/graph/graph-store.test.ts +++ b/src/renderer/src/graph/graph-store.test.ts @@ -208,6 +208,33 @@ describe('Graph renderer store', () => { expect(client.listRuns).toHaveBeenCalledTimes(2) }) + it('keeps periodic Graph snapshot reconciliation silent', async () => { + let resolveRuns!: (runs: GraphRun[]) => void + client.listRuns.mockReturnValueOnce(new Promise((resolve) => { + resolveRuns = resolve + })) + useGraphStore.setState({ + threadId: 'thread_1', + loading: false, + error: 'Keep an unrelated action error visible.' + }) + + const refresh = useGraphStore.getState().refreshThread('thread_1', { silent: true }) + expect(useGraphStore.getState()).toMatchObject({ + loading: false, + error: 'Keep an unrelated action error visible.' + }) + + resolveRuns([run('run_1', 2)]) + await refresh + + expect(useGraphStore.getState()).toMatchObject({ + loading: false, + error: 'Keep an unrelated action error visible.', + runs: [{ id: 'run_1', lastEventSeq: 2 }] + }) + }) + it('reconciles planning events and resumes with the current draft revision', async () => { useGraphStore.setState({ threadId: 'thread_1', diff --git a/src/renderer/src/graph/graph-store.ts b/src/renderer/src/graph/graph-store.ts index ddee21baa..ef81d408f 100644 --- a/src/renderer/src/graph/graph-store.ts +++ b/src/renderer/src/graph/graph-store.ts @@ -31,6 +31,10 @@ import type { export { selectGraphPlanningCorrectionDraft } from './graph-planning-selection' +type GraphThreadRefreshOptions = { + silent?: boolean +} + type GraphViewState = { threadId: string | null workspace: string @@ -54,7 +58,7 @@ type GraphViewState = { wakingObligationId: string | null loading: boolean error: string | null - refreshThread: (threadId: string | null) => Promise + refreshThread: (threadId: string | null, options?: GraphThreadRefreshOptions) => Promise refreshProject: (workspace: string) => Promise refreshSelectedRun: () => Promise selectRun: (runId: string | null) => void @@ -131,8 +135,9 @@ export const useGraphStore = create((set, get) => ({ loading: false, error: null, - refreshThread: async (threadId) => { - set({ threadId, loading: true, error: null }) + refreshThread: async (threadId, options) => { + const silent = options?.silent === true + set(silent ? { threadId } : { threadId, loading: true, error: null }) if (!threadId) { set({ runs: [], @@ -174,10 +179,10 @@ export const useGraphStore = create((set, get) => ({ artifactPage: null, artifactContent: '', artifactLoading: false, - loading: false + ...(silent ? {} : { loading: false, error: null }) }) } catch (error) { - set({ loading: false, error: message(error) }) + if (!silent) set({ loading: false, error: message(error) }) } }, From c67eb364a54a6f297a88c45921495b42f53e2e81 Mon Sep 17 00:00:00 2001 From: xingyu Date: Tue, 4 Aug 2026 10:10:58 +0800 Subject: [PATCH 4/6] fix(release): make Windows validation platform-safe (#1075) --- build/installer.nsh | 52 ++++++++++++-- build/windows-installer-migration.ps1 | 18 ++++- kun/package-lock.json | 70 ++++++++----------- kun/package.json | 2 +- .../component-design-tool-provider.test.ts | 3 +- kun/src/adapters/tool/local-tool-host.test.ts | 10 ++- kun/src/adapters/tool/ppt-master-tool.test.ts | 2 +- kun/src/artifacts/artifact-store.test.ts | 8 ++- kun/src/cli/gui-settings-bridge.test.ts | 4 +- kun/src/graph/graph-attempt-scheduler.ts | 10 ++- kun/src/graph/graph-module-boundaries.test.ts | 6 +- ...aph-scheduler-supervision-liveness.test.ts | 70 ++++++++++++++++++- kun/src/graph/graph-scheduler.ts | 36 +++------- .../server/routes/extension-public.test.ts | 47 +++++++------ .../services/extension-agent-service.test.ts | 3 +- .../services/extension-host-broker.test.ts | 12 ++-- .../model-request-trace-store.test.ts | 6 +- .../services/official-provider-cli.test.ts | 2 +- kun/src/services/opencode-go-local-quota.ts | 4 +- .../runtime-migration-import-service.test.ts | 6 +- kun/src/telemetry/agent-observability.test.ts | 6 +- kun/src/tui/options.test.ts | 3 +- kun/src/tui/pasted-paths.test.ts | 11 ++- kun/tests/attachment-store.test.ts | 8 ++- kun/tests/background-shell-output.test.ts | 6 +- kun/tests/delegation-runtime.test.ts | 6 +- kun/tests/extension-package.test.ts | 4 +- kun/tests/memory-store.test.ts | 6 +- kun/tests/thread-store-doctor-race.test.ts | 6 +- kun/tests/thread-store-repair.test.ts | 6 +- package-lock.json | 62 ++++------------ .../test/scaffold.test.ts | 18 ++++- scripts/after-pack.cjs | 2 +- scripts/check-production-audit.mjs | 7 +- scripts/smoke-windows-installer-migration.ps1 | 52 ++++++++++---- src/main/app-identity.test.ts | 3 +- .../application-state-migration.test.ts | 3 +- .../ipc/register-app-ipc-handlers.test.ts | 16 ++++- src/main/packaged-install-health.test.ts | 20 +++++- src/main/packaged-install-health.ts | 37 +++++++++- src/main/packaging-config.test.ts | 27 ++++++- ...time-data-dir-preserving-migration.test.ts | 10 +-- src/main/runtime/kun-adapter.test.ts | 19 ++++- .../services/git-checkpoint-service.test.ts | 2 + .../services/prototype-embed-registry.test.ts | 3 +- src/main/services/workspace-paths.ts | 11 +-- .../write-infographic-service.test.ts | 7 +- src/main/windows-installer-migration.test.ts | 29 ++++++++ .../src/components/design/DesignSidebar.tsx | 10 +-- src/renderer/src/graph/graph-store.ts | 5 +- src/renderer/src/locales/hi/common.json | 2 + src/renderer/src/locales/ja/common.json | 2 + src/renderer/src/locales/ko/common.json | 2 + src/renderer/src/locales/ru/common.json | 2 + src/renderer/src/locales/th/common.json | 2 + 55 files changed, 531 insertions(+), 255 deletions(-) diff --git a/build/installer.nsh b/build/installer.nsh index e680f4c4b..185964316 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -31,11 +31,25 @@ Var /GLOBAL KunInstallerStopResult !endif !macro kunRunMigrationHelper ACTION - nsExec::ExecToStack `"$KunInstallerPowerShellPath" -NoProfile -ExecutionPolicy Bypass -File "$KunInstallerHelperPath" -Action ${ACTION}` + !ifdef BUILD_UNINSTALLER + nsExec::ExecToStack `"$KunInstallerPowerShellPath" -NoProfile -ExecutionPolicy Bypass -File "$KunInstallerHelperPath" -Action ${ACTION}` + !else + nsExec::ExecToStack `"$KunInstallerPowerShellPath" -NoProfile -ExecutionPolicy Bypass -File "$KunInstallerHelperPath" -Action ${ACTION} -ResultPath "$KunInstallerResultPath"` + !endif Pop $KunInstallerHelperExitCode Pop $KunInstallerHelperOutput !macroend +!macro kunSetEnvironmentFromRegister NAME REGISTER + # System::Call reparses quoted variable expansions. Copy arbitrary registry + # text into a local NSIS register and let the plugin read it directly so an + # UninstallString containing quotes is preserved verbatim. + Push $9 + StrCpy $9 ${REGISTER} + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("${NAME}", r9).r0' + Pop $9 +!macroend + !macro customPageAfterChangeDir !define MUI_PAGE_CUSTOMFUNCTION_PRE KunInstallDirectoryPagePre !define MUI_PAGE_CUSTOMFUNCTION_LEAVE KunInstallDirectoryPageLeave @@ -263,7 +277,7 @@ Var /GLOBAL KunInstallerStopResult Function KunResolveRegisteredSource System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_SOURCE", "$KunInstallerSourceDir").r0' - System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_UNINSTALL_STRING", "$R9").r0' + !insertmacro kunSetEnvironmentFromRegister "KUN_INSTALLER_UNINSTALL_STRING" $R9 Delete "$KunInstallerResultPath" !insertmacro kunRunMigrationHelper ResolveSource ${if} $KunInstallerHelperExitCode == 0 @@ -284,7 +298,35 @@ Var /GLOBAL KunInstallerStopResult ${endif} ReadEnvStr $KunInstallerUpdateSourceDir "KUN_INSTALLER_UPDATE_SOURCE" ${if} $KunInstallerUpdateSourceDir == "" - DetailPrint "Automatic update source marker is unavailable; using compatible single-registration selection." + # Older Kun versions did not export the running application directory. + # Select an unambiguous single registration explicitly because the + # updater's --updated path may otherwise retain the default install mode. + ReadRegStr $R0 HKEY_CURRENT_USER "${INSTALL_REGISTRY_KEY}" InstallLocation + ReadRegStr $R1 HKEY_CURRENT_USER "${UNINSTALL_REGISTRY_KEY}" UninstallString + ReadRegStr $R2 HKEY_LOCAL_MACHINE "${INSTALL_REGISTRY_KEY}" InstallLocation + ReadRegStr $R3 HKEY_LOCAL_MACHINE "${UNINSTALL_REGISTRY_KEY}" UninstallString + ${if} $R0 == "" + ${andIf} $R1 == "" + ${if} $R2 != "" + ${orIf} $R3 != "" + StrCpy $hasPerMachineInstallation 1 + StrCpy $hasPerUserInstallation 0 + !insertmacro setInstallModePerAllUsers + DetailPrint "Automatic update selected the only registered all-users ${PRODUCT_NAME} installation." + ${else} + DetailPrint "Automatic update found no existing ${PRODUCT_NAME} registration; keeping the requested install mode." + ${endif} + Return + ${endif} + ${if} $R2 == "" + ${andIf} $R3 == "" + StrCpy $hasPerMachineInstallation 0 + StrCpy $hasPerUserInstallation 1 + !insertmacro setInstallModePerUser + DetailPrint "Automatic update selected the only registered current-user ${PRODUCT_NAME} installation." + Return + ${endif} + DetailPrint "Automatic update source marker is unavailable with registrations in both scopes; keeping the requested install mode." Return ${endif} @@ -293,9 +335,9 @@ Var /GLOBAL KunInstallerStopResult ReadRegStr $R2 HKEY_LOCAL_MACHINE "${INSTALL_REGISTRY_KEY}" InstallLocation ReadRegStr $R3 HKEY_LOCAL_MACHINE "${UNINSTALL_REGISTRY_KEY}" UninstallString System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_CURRENT_USER_SOURCE", "$R0").r0' - System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_CURRENT_USER_UNINSTALL_STRING", "$R1").r0' + !insertmacro kunSetEnvironmentFromRegister "KUN_INSTALLER_CURRENT_USER_UNINSTALL_STRING" $R1 System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_ALL_USERS_SOURCE", "$R2").r0' - System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_ALL_USERS_UNINSTALL_STRING", "$R3").r0' + !insertmacro kunSetEnvironmentFromRegister "KUN_INSTALLER_ALL_USERS_UNINSTALL_STRING" $R3 Delete "$KunInstallerResultPath" !insertmacro kunRunMigrationHelper ResolveUpdateScope ${if} $KunInstallerHelperExitCode == 0 diff --git a/build/windows-installer-migration.ps1 b/build/windows-installer-migration.ps1 index 28f9e4e7b..d99d11ab4 100644 --- a/build/windows-installer-migration.ps1 +++ b/build/windows-installer-migration.ps1 @@ -327,6 +327,7 @@ function Write-InstallerResult([string]$Value) { $resultPath = Join-Path $PSScriptRoot 'kun-windows-installer-result.txt' } [IO.File]::WriteAllBytes($resultPath, [Text.Encoding]::Unicode.GetBytes($Value)) + Write-InstallerDiagnostic "RESULT path=$resultPath length=$($Value.Length)" [Console]::Out.Write($Value) } @@ -386,12 +387,23 @@ function Convert-IdentityToSid([string]$Identity) { ) } +function Get-FileSystemSecurity([string]$PathValue) { + $sections = [Security.AccessControl.AccessControlSections]::All + if ([IO.Directory]::Exists($PathValue)) { + return [IO.Directory]::GetAccessControl($PathValue, $sections) + } + if ([IO.File]::Exists($PathValue)) { + return [IO.File]::GetAccessControl($PathValue, $sections) + } + throw "The ACL target does not exist: $PathValue" +} + function Test-JournalAclSecure([string]$PathValue, [string]$Mode) { try { if (Test-ReparsePoint $PathValue) { return $false } - $security = Get-Acl -LiteralPath $PathValue + $security = Get-FileSystemSecurity $PathValue $owner = Convert-IdentityToSid $security.Owner $expectedOwner = Get-JournalAclOwnerSid $Mode $systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') @@ -443,7 +455,7 @@ function Set-SecureJournalDirectoryAcl([string]$Directory, [string]$Mode) { ) [void]$security.AddAccessRule($rule) } - Set-Acl -LiteralPath $Directory -AclObject $security + [IO.Directory]::SetAccessControl($Directory, $security) } function Set-SecureJournalFileAcl([string]$PathValue, [string]$Mode) { @@ -461,7 +473,7 @@ function Set-SecureJournalFileAcl([string]$PathValue, [string]$Mode) { ) [void]$security.AddAccessRule($rule) } - Set-Acl -LiteralPath $PathValue -AclObject $security + [IO.File]::SetAccessControl($PathValue, $security) } function Assert-JournalStorageTrusted { diff --git a/kun/package-lock.json b/kun/package-lock.json index 24eae1b22..029fbd2f4 100644 --- a/kun/package-lock.json +++ b/kun/package-lock.json @@ -38,7 +38,8 @@ "zod": "^4.4.3" }, "bin": { - "kun": "dist/cli/serve-entry.js" + "kun": "dist/cli/serve-entry.js", + "kun-dv": "dist/cli/serve-entry.js" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", @@ -247,6 +248,7 @@ "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -265,8 +267,7 @@ "version": "1.10.0", "resolved": "https://registry.npmmirror.com/@bufbuild/protobuf/-/protobuf-1.10.0.tgz", "integrity": "sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==", - "license": "(Apache-2.0 AND BSD-3-Clause)", - "peer": true + "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@computer-use/default-clipboard-provider": { "version": "4.2.0", @@ -425,7 +426,6 @@ "darwin", "win32" ], - "peer": true, "dependencies": { "@computer-use/default-clipboard-provider": "4.2.0", "@computer-use/libnut": "4.2.0", @@ -520,7 +520,6 @@ "resolved": "https://registry.npmmirror.com/@connectrpc/connect/-/connect-1.7.0.tgz", "integrity": "sha512-iNKdJRi69YP3mq6AePRT8F/HrxWCewrhxnLMNm0vpqXAR8biwzRtO6Hjx80C6UvtKJ5sFmffQT7I4Baecz389w==", "license": "Apache-2.0", - "peer": true, "peerDependencies": { "@bufbuild/protobuf": "^1.10.0" } @@ -542,9 +541,9 @@ } }, "node_modules/@connectrpc/connect-node/node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -691,7 +690,6 @@ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", "license": "MIT", - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" @@ -702,7 +700,6 @@ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -816,7 +813,6 @@ "resolved": "https://registry.npmmirror.com/@jimp/custom/-/custom-0.22.12.tgz", "integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/core": "^0.22.12" } @@ -1087,7 +1083,6 @@ "resolved": "https://registry.npmmirror.com/@jimp/plugin-blur/-/plugin-blur-1.6.1.tgz", "integrity": "sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/core": "1.6.1", "@jimp/utils": "1.6.1" @@ -1461,7 +1456,6 @@ "resolved": "https://registry.npmmirror.com/@jimp/plugin-resize/-/plugin-resize-1.6.1.tgz", "integrity": "sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/core": "1.6.1", "@jimp/types": "1.6.1", @@ -1511,7 +1505,6 @@ "resolved": "https://registry.npmmirror.com/@jimp/plugin-scale/-/plugin-scale-0.22.12.tgz", "integrity": "sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -1616,7 +1609,6 @@ "resolved": "https://registry.npmmirror.com/@jimp/plugin-blit/-/plugin-blit-0.22.12.tgz", "integrity": "sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -1653,7 +1645,6 @@ "resolved": "https://registry.npmmirror.com/@jimp/plugin-color/-/plugin-color-0.22.12.tgz", "integrity": "sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12", "tinycolor2": "^1.6.0" @@ -1697,7 +1688,6 @@ "resolved": "https://registry.npmmirror.com/@jimp/plugin-crop/-/plugin-crop-0.22.12.tgz", "integrity": "sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -1785,7 +1775,6 @@ "resolved": "https://registry.npmmirror.com/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz", "integrity": "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -1798,7 +1787,6 @@ "resolved": "https://registry.npmmirror.com/@jimp/plugin-rotate/-/plugin-rotate-0.22.12.tgz", "integrity": "sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -2022,7 +2010,6 @@ "resolved": "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", - "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", @@ -2389,7 +2376,8 @@ "version": "1.0.1", "resolved": "https://registry.npmmirror.com/@stablelib/base64/-/base64-1.0.1.tgz", "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@standard-schema/spec": { "version": "1.1.0", @@ -3482,7 +3470,6 @@ "resolved": "https://registry.npmmirror.com/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -3549,12 +3536,13 @@ "version": "1.3.0", "resolved": "https://registry.npmmirror.com/fast-sha256/-/fast-sha256-1.3.0.tgz", "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", - "license": "Unlicense" + "license": "Unlicense", + "peer": true }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -3845,11 +3833,10 @@ } }, "node_modules/hono": { - "version": "4.12.30", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.30.tgz", - "integrity": "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==", + "version": "4.12.34", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.34.tgz", + "integrity": "sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -3966,9 +3953,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -4105,6 +4092,7 @@ "resolved": "https://registry.npmmirror.com/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" @@ -4904,7 +4892,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5111,8 +5098,7 @@ "version": "2.2.0", "resolved": "https://registry.npmmirror.com/quickjs-wasi/-/quickjs-wasi-2.2.0.tgz", "integrity": "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/range-parser": { "version": "1.2.1", @@ -5647,6 +5633,7 @@ "resolved": "https://registry.npmmirror.com/standardwebhooks/-/standardwebhooks-1.0.0.tgz", "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", "license": "MIT", + "peer": true, "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" @@ -5832,7 +5819,8 @@ "version": "2.0.0", "resolved": "https://registry.npmmirror.com/ts-algebra/-/ts-algebra-2.0.0.tgz", "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tslib": { "version": "2.8.1", @@ -5921,9 +5909,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmmirror.com/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -5975,7 +5963,6 @@ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -6273,7 +6260,6 @@ "resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/kun/package.json b/kun/package.json index ca1a3ce68..a3c130c27 100644 --- a/kun/package.json +++ b/kun/package.json @@ -113,7 +113,7 @@ "overrides": { "@hono/node-server": "2.0.11", "@connectrpc/connect-node": { - "undici": "6.27.0" + "undici": "6.28.0" } } } diff --git a/kun/src/adapters/tool/component-design-tool-provider.test.ts b/kun/src/adapters/tool/component-design-tool-provider.test.ts index cd9189008..39ef9b521 100644 --- a/kun/src/adapters/tool/component-design-tool-provider.test.ts +++ b/kun/src/adapters/tool/component-design-tool-provider.test.ts @@ -137,7 +137,8 @@ describe('design_component tool', () => { sandboxMode: 'workspace-write', security: expect.objectContaining({ memoryEnabled: false }) }) - expect(String(prompts[0]?.workspace)).toMatch(/\.kun-design\/component-prototypes\/.+$/) + expect(String(prompts[0]?.workspace).replaceAll('\\', '/')) + .toMatch(/\.kun-design\/component-prototypes\/.+$/) expect(String(prompts[0]?.prompt)).toContain('Required output path: prototype.html') expect(String(prompts[0]?.prompt)).toContain('') expect(String(prompts[0]?.prompt)).toContain('export function DatePicker') diff --git a/kun/src/adapters/tool/local-tool-host.test.ts b/kun/src/adapters/tool/local-tool-host.test.ts index b676b64b2..2be35c596 100644 --- a/kun/src/adapters/tool/local-tool-host.test.ts +++ b/kun/src/adapters/tool/local-tool-host.test.ts @@ -496,7 +496,11 @@ describe('LocalToolHost approval policy', () => { expect(awaitApproval).toHaveBeenCalledOnce() expect(awaitApproval).toHaveBeenCalledWith(expect.objectContaining({ - summary: expect.stringContaining(physicalTarget) + action: expect.objectContaining({ + targets: expect.arrayContaining([ + expect.objectContaining({ value: physicalTarget }) + ]) + }) })) expect(result.item).toMatchObject({ isError: false }) expect(context).not.toHaveProperty('approvedExternalWriteTargets') @@ -831,6 +835,10 @@ describe('LocalToolHost approval policy', () => { }) it('rejects edit when the parent is swapped after open but before identity verification', async (testContext) => { + if (process.platform === 'win32') { + testContext.skip() + return + } const parent = await mkdtemp(join(tmpdir(), 'kun-external-edit-race-')) const workspace = join(parent, 'workspace') const approvedDirectory = join(parent, 'approved') diff --git a/kun/src/adapters/tool/ppt-master-tool.test.ts b/kun/src/adapters/tool/ppt-master-tool.test.ts index b6224a2b3..837189ffb 100644 --- a/kun/src/adapters/tool/ppt-master-tool.test.ts +++ b/kun/src/adapters/tool/ppt-master-tool.test.ts @@ -148,7 +148,7 @@ describe('PPT Master local tool', () => { join(skillDir, 'scripts', 'svg_to_pptx.py'), projectPath, '--output', - expect.stringMatching(/\/presentations\/\.brief\.[^.]+\.tmp\.pptx$/), + expect.stringMatching(/[\\/]presentations[\\/]\.brief\.[^.]+\.tmp\.pptx$/), '--quiet' ]) }) diff --git a/kun/src/artifacts/artifact-store.test.ts b/kun/src/artifacts/artifact-store.test.ts index 0aed9b62a..ea662aaec 100644 --- a/kun/src/artifacts/artifact-store.test.ts +++ b/kun/src/artifacts/artifact-store.test.ts @@ -130,9 +130,11 @@ describe('FileArtifactStore streaming reads', () => { const store = new FileArtifactStore(dir, () => 't0') const result = await store.put({ content: 'sensitive artifact' }) - expect((await stat(dir)).mode & 0o777).toBe(0o700) - expect((await stat(join(dir, `${result.meta.id}.bin`))).mode & 0o777).toBe(0o600) - expect((await stat(join(dir, `${result.meta.id}.json`))).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') { + expect((await stat(dir)).mode & 0o777).toBe(0o700) + expect((await stat(join(dir, `${result.meta.id}.bin`))).mode & 0o777).toBe(0o600) + expect((await stat(join(dir, `${result.meta.id}.json`))).mode & 0o777).toBe(0o600) + } } finally { await rm(dir, { recursive: true, force: true }) } diff --git a/kun/src/cli/gui-settings-bridge.test.ts b/kun/src/cli/gui-settings-bridge.test.ts index 9b2298f1b..a6b4ea0b8 100644 --- a/kun/src/cli/gui-settings-bridge.test.ts +++ b/kun/src/cli/gui-settings-bridge.test.ts @@ -161,7 +161,9 @@ describe('GUI settings bridge', () => { expect(config.serve.providers.codex.apiKey).toBe('') expect(config.serve.providers.codex.credentialSourceId).toBe('settings:provider:codex') expect(config.capabilities.futureGuiCapability).toEqual({ enabled: true, protocol: 'future-v2' }) - expect((await stat(configPath)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') { + expect((await stat(configPath)).mode & 0o777).toBe(0o600) + } expect(result?.applyRequest.serve?.providers?.codex?.models).toEqual(['gpt-5.6-luna', 'gpt-5.6-sol']) expect(result?.applyRequest.serve).toMatchObject({ approvalPolicy: 'auto', diff --git a/kun/src/graph/graph-attempt-scheduler.ts b/kun/src/graph/graph-attempt-scheduler.ts index 92e58a0ed..7422cc016 100644 --- a/kun/src/graph/graph-attempt-scheduler.ts +++ b/kun/src/graph/graph-attempt-scheduler.ts @@ -502,7 +502,15 @@ export abstract class GraphAttemptScheduler { // Test/embedded supervisors may synchronously record a Lead review and // wake reconciliation; keeping the queue here would deadlock that wake. if (submittedSummary !== undefined) { - await this.requestSupervision(runId, 'submitted', [nodeId], submittedSummary) + try { + await this.requestSupervision(runId, 'submitted', [nodeId], submittedSummary) + } catch (error) { + // The result is already durable; reconciliation can redeliver a + // notification conflict without downgrading the submitted attempt. + if (!(error instanceof GraphRunConflictError)) throw error + console.warn(`[kun] Graph supervision raced durable state for ${attemptId}; ` + + 'leaving the result reviewable for reconciliation.') + } } const latest = await this.requireRun(runId) if (isTerminalRunStatus(latest.status)) { diff --git a/kun/src/graph/graph-module-boundaries.test.ts b/kun/src/graph/graph-module-boundaries.test.ts index 65de10a88..cf3c37cec 100644 --- a/kun/src/graph/graph-module-boundaries.test.ts +++ b/kun/src/graph/graph-module-boundaries.test.ts @@ -1,6 +1,6 @@ import { readdirSync, readFileSync, statSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { basename, join, resolve } from 'node:path' +import { basename, join, relative, resolve } from 'node:path' import { describe, expect, it } from 'vitest' const REPOSITORY_ROOT = fileURLToPath(new URL('../../../', import.meta.url)) @@ -22,6 +22,7 @@ const GRAPH_ENTRY_FILES = [ resolve(REPOSITORY_ROOT, 'src/renderer/src/components/settings-section-graph-panel.tsx') ] const SOURCE_FILE_PATTERN = /\.(?:test\.)?[cm]?[tj]sx?$/ +const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[tj]sx?$/ function collectSourceFiles(directory: string): string[] { const files: string[] = [] @@ -57,8 +58,9 @@ describe('Graph module boundaries', () => { ...GRAPH_ENTRY_FILES ] const oversized = [...new Set(files)] + .filter((file) => !TEST_FILE_PATTERN.test(file)) .map((file) => ({ - file: file.slice(REPOSITORY_ROOT.length + 1), + file: relative(REPOSITORY_ROOT, file).replaceAll('\\', '/'), lines: lineCount(file) })) .filter(({ lines }) => lines > 700) diff --git a/kun/src/graph/graph-scheduler-supervision-liveness.test.ts b/kun/src/graph/graph-scheduler-supervision-liveness.test.ts index 414af3352..2fc0da809 100644 --- a/kun/src/graph/graph-scheduler-supervision-liveness.test.ts +++ b/kun/src/graph/graph-scheduler-supervision-liveness.test.ts @@ -17,6 +17,7 @@ import { const supervisors: GraphSupervisor[] = [] const supervisionLivenessTimeoutMs = 60_000 +const leadReviewConflictTimeoutMs = 5_000 afterEach(async () => { await Promise.all(supervisors.splice(0).map((supervisor) => supervisor.stop())) @@ -24,6 +25,50 @@ afterEach(async () => { }) describe('Graph scheduler supervision liveness', () => { + it('keeps a submitted result reviewable when its supervision notification conflicts', async () => { + const source = testGraphPlan().nodes[0]! + const plan = testGraphPlan({ + nodes: [source], + edges: [], + completionNodeIds: [source.id], + autoStart: true + }) + let submittedSignals = 0 + const supervision: GraphSupervisionPort = { + signal: async (input) => { + if (input.reason === 'submitted' && ++submittedSignals === 1) { + throw new GraphRunConflictError('simulated concurrent supervision append') + } + } + } + const harness = await schedulerHarness( + plan, + () => completedWorker(() => 1), + {}, + { autoLeadReview: false, supervision: () => supervision } + ) + + harness.scheduler.start() + const reviewing = await waitFor(async () => { + const run = await harness.store.get('run_harness') + return run?.nodes.research.status === 'reviewing' ? run : null + }) + const attempt = reviewing.nodes.research.attempts.at(-1)! + await recordLeadPass(harness, reviewing.id, ['research']) + await harness.scheduler.resumeRun(reviewing.id) + + const completed = await waitFor(async () => { + const run = await harness.store.get(reviewing.id) + return run?.status === 'completed' ? run : null + }) + const lease = (await harness.writes.list()).leases.find((entry) => + entry.attemptId === attempt.id) + expect(submittedSignals).toBeGreaterThanOrEqual(1) + expect(completed.nodes.research.status).toBe('accepted') + expect(lease).toMatchObject({ state: 'released', releaseDisposition: 'accepted' }) + await harness.scheduler.stop() + }) + it('redelivers two parked prose-only Lead episodes in-process before review completes the run', async () => { let nowMs = Date.now() let workerStarts = 0 @@ -115,6 +160,8 @@ describe('Graph scheduler supervision liveness', () => { }) } catch (error) { const run = await harness.store.get('run_harness') + const writeState = await harness.writes.list() + const events = await harness.store.events('run_harness') throw new Error( `${error instanceof Error ? error.message : String(error)}: ${JSON.stringify({ leadEpisodes, @@ -132,7 +179,20 @@ describe('Graph scheduler supervision liveness', () => { obligations: run?.supervisionObligations.map((obligation) => ({ kind: obligation.kind, state: obligation.state, - noProgressCount: obligation.noProgressCount + noProgressCount: obligation.noProgressCount, + attemptIds: obligation.attemptIds, + digest: obligation.digest + })), + leases: writeState.leases.map((lease) => ({ + leaseId: lease.leaseId, + attemptId: lease.attemptId, + state: lease.state, + releaseDisposition: lease.releaseDisposition + })), + events: events.slice(-20).map((event) => ({ + seq: event.graphSeq, + type: event.event.type, + payload: event.event.payload })) })}` ) @@ -346,7 +406,8 @@ async function recordLeadPass( nodeIds: readonly string[] ): Promise { for (const nodeId of nodeIds) { - for (let retry = 0; retry < 5; retry += 1) { + const conflictDeadline = Date.now() + leadReviewConflictTimeoutMs + while (true) { const run = await harness.store.get(runId) const node = run?.nodes[nodeId] const attempt = node?.attempts.at(-1) @@ -372,7 +433,10 @@ async function recordLeadPass( }, 'lead') break } catch (error) { - if (!(error instanceof GraphRunConflictError) || retry === 4) throw error + if (!(error instanceof GraphRunConflictError) || Date.now() >= conflictDeadline) throw error + // Let the scheduler's in-flight durable write settle before reading the + // latest attempt/review projection and retrying the idempotent command. + await new Promise((resolve) => setTimeout(resolve, 10)) } } } diff --git a/kun/src/graph/graph-scheduler.ts b/kun/src/graph/graph-scheduler.ts index 70091a31e..1264029ed 100644 --- a/kun/src/graph/graph-scheduler.ts +++ b/kun/src/graph/graph-scheduler.ts @@ -1,10 +1,5 @@ -import { - GRAPH_CONTRACT_VERSION, - type GraphDomainEventV1, - type GraphNodeAttemptV1, - type GraphNodeProjectionV1, - type GraphRunV1 -} from '../contracts/graph.js' +import { GRAPH_CONTRACT_VERSION, type GraphDomainEventV1, type GraphNodeAttemptV1, + type GraphNodeProjectionV1, type GraphRunV1 } from '../contracts/graph.js' import { GraphRunConflictError } from './graph-run-store.js' import { GraphAttemptScheduler } from './graph-attempt-scheduler.js' import { @@ -20,26 +15,14 @@ import { terminalRequiredFailure, validationFailureSummary } from './graph-scheduler-policy.js' -import { - loopGateHandlesNodeOutcome, - loopGateWaivesIncompleteNode -} from './graph-loop-policy.js' +import { loopGateHandlesNodeOutcome, loopGateWaivesIncompleteNode } from './graph-loop-policy.js' import { evaluateGraphLoopGates } from './graph-loop-gate-evaluator.js' -import { - finishGraphRun, - isGraphRunCompletionFinalizing, - tryCompleteGraphRun -} from './graph-run-completion.js' +import { finishGraphRun, isGraphRunCompletionFinalizing, + tryCompleteGraphRun } from './graph-run-completion.js' import { reconcileGraphReadiness } from './graph-readiness-reconciler.js' -import type { - GraphSchedulerOptions, - GraphSupervisionPort -} from './graph-scheduler-types.js' +import type { GraphSchedulerOptions, GraphSupervisionPort } from './graph-scheduler-types.js' import { recordGraphTerminalCleanup } from './graph-terminal-cleanup.js' -import { - deliverNodeSteering, - handleNodeAttemptSteering -} from './graph-steering-delivery.js' +import { deliverNodeSteering, handleNodeAttemptSteering } from './graph-steering-delivery.js' import { GraphPeerReviewCoordinator } from './graph-peer-review-coordinator.js' import { enforceGraphBudgets, recordGraphReconcileFailure } from './graph-scheduler-maintenance.js' export type { @@ -47,10 +30,7 @@ export type { GraphSchedulerOptions, GraphSupervisionPort } from './graph-scheduler-types.js' -export { - parseWorkerResult, - validateWorkerResult -} from './graph-scheduler-policy.js' +export { parseWorkerResult, validateWorkerResult } from './graph-scheduler-policy.js' export class GraphScheduler extends GraphAttemptScheduler { private readonly runQueues = new Map>() private readonly cleanupTasks = new Set>() diff --git a/kun/src/server/routes/extension-public.test.ts b/kun/src/server/routes/extension-public.test.ts index ae453d720..4164bde3c 100644 --- a/kun/src/server/routes/extension-public.test.ts +++ b/kun/src/server/routes/extension-public.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { parseExtensionManifest } from '@kun/extension-api' import { @@ -19,6 +19,7 @@ import { } from './extension-public.js' const cleanupRoots: string[] = [] +const WORKSPACE_ROOT = resolve('/workspace') afterEach(async () => { for (const root of cleanupRoots.splice(0)) await rm(root, { recursive: true, force: true }) @@ -192,13 +193,13 @@ describe('extension public routes', () => { expect(fixture.manager.activate).toHaveBeenCalledWith( 'acme.dashboard', 'onCommand:refresh', - { workspaceRoot: '/workspace' } + { workspaceRoot: WORKSPACE_ROOT } ) expect(fixture.broker.handlePrincipal).toHaveBeenCalledWith(expect.objectContaining({ principal: expect.objectContaining({ extensionId: 'acme.dashboard', extensionVersion: '1.0.0', - workspaceRoots: ['/workspace'] + workspaceRoots: [WORKSPACE_ROOT] }), method: 'commands.execute', params: { id: 'refresh', args: { source: 'topbar' } } @@ -372,15 +373,15 @@ describe('extension public routes', () => { const created = await dispatchJson(router, 'POST', '/v1/extensions/view-sessions', { contributionId: 'extension:acme.dashboard/panel', - workspaceRoot: '/workspace' + workspaceRoot: WORKSPACE_ROOT }, runtimeHeaders()) expect(created.status).toBe(201) const expectedActivationOptions = { - workspaceRoot: '/workspace', + workspaceRoot: WORKSPACE_ROOT, workspaceContext: { - id: fixture.paths.workspaceKey('/workspace'), + id: fixture.paths.workspaceKey(WORKSPACE_ROOT), name: 'workspace', - root: '/workspace', + root: WORKSPACE_ROOT, trusted: true, active: true } @@ -684,7 +685,7 @@ describe('extension public routes', () => { extensionId: 'acme.dashboard', extensionVersion: '1.0.0', contributionId: 'extension:acme.dashboard/panel', - workspaceRoot: '/workspace', + workspaceRoot: WORKSPACE_ROOT, senderWebContentsId: 42, senderMainFrameProcessId: 7, senderMainFrameRoutingId: 11 @@ -734,10 +735,10 @@ describe('extension public routes', () => { extensionVersion: '1.0.0', viewSessionId: created.body.sessionId, viewContributionId: 'extension:acme.dashboard/panel', - workspaceRoots: ['/workspace'] + workspaceRoots: [WORKSPACE_ROOT] }), { - workspaceRoot: '/workspace', + workspaceRoot: WORKSPACE_ROOT, path: '/private/media/interview.mp4', mode: 'read', source: 'picker', @@ -764,7 +765,7 @@ describe('extension public routes', () => { const router = buildExtensionPublicRouter(fixture.runtime) const created = await dispatchJson(router, 'POST', '/v1/extensions/view-sessions', { contributionId: 'extension:acme.dashboard/panel', - workspaceRoot: '/workspace' + workspaceRoot: WORKSPACE_ROOT }, runtimeHeaders()) const binding = { sessionId: created.body.sessionId, @@ -773,7 +774,7 @@ describe('extension public routes', () => { extensionId: 'acme.dashboard', extensionVersion: '1.0.0', contributionId: 'extension:acme.dashboard/panel', - workspaceRoot: '/workspace', + workspaceRoot: WORKSPACE_ROOT, senderWebContentsId: 42, senderMainFrameProcessId: 7, senderMainFrameRoutingId: 11 @@ -810,7 +811,7 @@ describe('extension public routes', () => { const router = buildExtensionPublicRouter(fixture.runtime) const created = await dispatchJson(router, 'POST', '/v1/extensions/view-sessions', { contributionId: 'extension:acme.dashboard/panel', - workspaceRoot: '/workspace' + workspaceRoot: WORKSPACE_ROOT }, runtimeHeaders()) const binding = { sessionId: created.body.sessionId, @@ -819,7 +820,7 @@ describe('extension public routes', () => { extensionId: 'acme.dashboard', extensionVersion: '1.0.0', contributionId: 'extension:acme.dashboard/panel', - workspaceRoot: '/workspace', + workspaceRoot: WORKSPACE_ROOT, senderWebContentsId: 42, senderMainFrameProcessId: 7, senderMainFrameRoutingId: 11 @@ -847,7 +848,7 @@ describe('extension public routes', () => { expect.objectContaining({ extensionId: 'acme.dashboard', viewSessionId: created.body.sessionId, - workspaceRoots: ['/workspace'] + workspaceRoots: [WORKSPACE_ROOT] }), 'media_handle_0000000001', 'read' @@ -857,8 +858,8 @@ describe('extension public routes', () => { artifactId: 'artifact_1234567890', ownerExtensionId: 'acme.dashboard', ownerExtensionVersion: '1.0.0', - workspaceId: fixture.paths.workspaceKey('/workspace'), - workspaceRoot: '/workspace' + workspaceId: fixture.paths.workspaceKey(WORKSPACE_ROOT), + workspaceRoot: WORKSPACE_ROOT }, runtimeHeaders()) expect(artifact).toMatchObject({ status: 200, @@ -873,7 +874,7 @@ describe('extension public routes', () => { artifactId: 'artifact_1234567890', ownerExtensionId: 'acme.dashboard', ownerExtensionVersion: '1.0.0', - workspaceId: fixture.paths.workspaceKey('/workspace'), + workspaceId: fixture.paths.workspaceKey(WORKSPACE_ROOT), workspaceRoot: '/other-workspace' }, runtimeHeaders()) expect(forgedWorkspace.status).toBe(404) @@ -1085,13 +1086,13 @@ describe('extension public routes', () => { expect(fixture.manager.activate).toHaveBeenCalledWith( 'acme.dashboard', 'onAuthentication:key-auth', - { workspaceRoot: '/workspace' } + { workspaceRoot: WORKSPACE_ROOT } ) expect(fixture.broker.handleTrustedManagement).toHaveBeenCalledWith(expect.objectContaining({ principal: expect.objectContaining({ extensionId: 'acme.dashboard', extensionVersion: '1.0.0', - workspaceRoots: ['/workspace'] + workspaceRoots: [WORKSPACE_ROOT] }), method: 'authentication.createSession' })) @@ -1503,7 +1504,7 @@ async function createFixture(options: { await registry.registerDevelopment('acme.dashboard', development) await registry.setWorkspacePermissionGrant( 'acme.dashboard', - paths.workspaceKey('/workspace'), + paths.workspaceKey(WORKSPACE_ROOT), permissions, development.manifest.version ) @@ -1643,7 +1644,7 @@ async function createFixture(options: { available: true, createdAt: '2026-07-13T00:00:00.000Z', absolutePath: '/private/media/interview.mp4', - workspaceRoot: '/workspace', + workspaceRoot: WORKSPACE_ROOT, ownerExtensionId: 'acme.dashboard', ownerExtensionVersion: '1.0.0', identity: { size: 1234, mtimeMs: 1000, device: 2, inode: 3 } @@ -1655,7 +1656,7 @@ async function createFixture(options: { artifactId, ownerExtensionId: 'acme.dashboard', ownerExtensionVersion: '1.0.0', - workspaceId: paths.workspaceKey('/workspace'), + workspaceId: paths.workspaceKey(WORKSPACE_ROOT), mediaHandleId: 'media_handle_0000000001', displayName: 'interview.mp4', mediaKind: 'video', diff --git a/kun/src/services/extension-agent-service.test.ts b/kun/src/services/extension-agent-service.test.ts index 66a7e1e4c..4826521af 100644 --- a/kun/src/services/extension-agent-service.test.ts +++ b/kun/src/services/extension-agent-service.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { resolve } from 'node:path' import { InMemoryEventBus } from '../adapters/in-memory-event-bus.js' import { InMemorySessionStore } from '../adapters/in-memory-session-store.js' import { InMemoryThreadStore } from '../adapters/in-memory-thread-store.js' @@ -16,7 +17,7 @@ import { } from './extension-agent-service.js' import { ExtensionAgentProfileRegistry } from './extension-agent-profile-registry.js' -const workspace = '/tmp/kun-extension-workspace' +const workspace = resolve('/tmp/kun-extension-workspace') function createHarness(headless = false) { const threadStore = new InMemoryThreadStore() diff --git a/kun/src/services/extension-host-broker.test.ts b/kun/src/services/extension-host-broker.test.ts index 3d56189f8..fdc53b3ef 100644 --- a/kun/src/services/extension-host-broker.test.ts +++ b/kun/src/services/extension-host-broker.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { ExtensionManifestSchema, MediaCreateCacheTargetResultSchema, @@ -18,7 +18,7 @@ import { } from './extension-host-broker.js' import { ExtensionMediaHandleService } from './extension-media-handle-service.js' -const WORKSPACE_ROOT = '/tmp/workspace' +const WORKSPACE_ROOT = resolve('/tmp/workspace') const WORKSPACE_ID = extensionWorkspaceKey(WORKSPACE_ROOT) const manifest = ExtensionManifestSchema.parse({ @@ -885,8 +885,8 @@ describe('ExtensionHostBroker', () => { }) it('routes workspace commands to their owning Host and disposes one Host generation only', async () => { - const workspaceA = '/tmp/workspace-a' - const workspaceB = '/tmp/workspace-b' + const workspaceA = resolve('/tmp/workspace-a') + const workspaceB = resolve('/tmp/workspace-b') const principalA: HostPrincipal = { ...principal, lifecycleNonce: 'host-workspace-a', @@ -964,8 +964,8 @@ describe('ExtensionHostBroker', () => { }) it('disposes broker registrations only in the revoked extension workspace', async () => { - const workspaceA = '/tmp/workspace-a' - const workspaceB = '/tmp/workspace-b' + const workspaceA = resolve('/tmp/workspace-a') + const workspaceB = resolve('/tmp/workspace-b') const invokeExtension = vi.fn(async ( _extensionId: string, _event: string, diff --git a/kun/src/services/model-request-trace-store.test.ts b/kun/src/services/model-request-trace-store.test.ts index 51e4e6f2d..9b4b58c6a 100644 --- a/kun/src/services/model-request-trace-store.test.ts +++ b/kun/src/services/model-request-trace-store.test.ts @@ -32,8 +32,10 @@ describe('ModelRequestTraceStore', () => { const root = join(dataDir, 'observability', 'model-http') const files = await import('node:fs/promises').then((fs) => fs.readdir(root)) expect(files).toHaveLength(1) - expect((await stat(root)).mode & 0o777).toBe(0o700) - expect((await stat(join(root, files[0]))).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') { + expect((await stat(root)).mode & 0o777).toBe(0o700) + expect((await stat(join(root, files[0]))).mode & 0o777).toBe(0o600) + } }) it('ignores a malformed trailing line and deletes only the selected thread', async () => { diff --git a/kun/src/services/official-provider-cli.test.ts b/kun/src/services/official-provider-cli.test.ts index 3af3599ff..fc3b56a39 100644 --- a/kun/src/services/official-provider-cli.test.ts +++ b/kun/src/services/official-provider-cli.test.ts @@ -27,7 +27,7 @@ describe('official provider CLI authentication', () => { command: process.execPath, displayName: 'Gemini CLI' }) - expect(command?.args[0]).toContain('@google/gemini-cli') + expect(command?.args[0].replaceAll('\\', '/')).toContain('@google/gemini-cli') }) it('rejects an Antigravity download before extraction when its checksum is invalid', async () => { diff --git a/kun/src/services/opencode-go-local-quota.ts b/kun/src/services/opencode-go-local-quota.ts index a3aea1631..8c7c6dde0 100644 --- a/kun/src/services/opencode-go-local-quota.ts +++ b/kun/src/services/opencode-go-local-quota.ts @@ -1,6 +1,6 @@ import { access } from 'node:fs/promises' import { homedir } from 'node:os' -import { join, win32 } from 'node:path' +import { posix, win32 } from 'node:path' import type { ProviderQuotaMetric } from '../contracts/provider-quota.js' const FIVE_HOURS_MS = 5 * 60 * 60 * 1_000 @@ -81,7 +81,7 @@ export function resolveOpenCodeGoDatabasePath( const environment = options.environment ?? process.env const userHome = options.homeDirectory ?? homedir() const platform = options.platform ?? process.platform - const joinPath = platform === 'win32' ? win32.join : join + const joinPath = platform === 'win32' ? win32.join : posix.join const xdgDataHome = environment.XDG_DATA_HOME?.trim() const dataRoot = xdgDataHome || joinPath(userHome, '.local', 'share') return joinPath(dataRoot, 'opencode', 'opencode.db') diff --git a/kun/src/services/runtime-migration-import-service.test.ts b/kun/src/services/runtime-migration-import-service.test.ts index ad1ae3d9a..494405f60 100644 --- a/kun/src/services/runtime-migration-import-service.test.ts +++ b/kun/src/services/runtime-migration-import-service.test.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { InMemorySessionStore } from '../adapters/in-memory-session-store.js' import { InMemoryThreadStore } from '../adapters/in-memory-thread-store.js' @@ -252,8 +252,10 @@ describe('RuntimeMigrationImportService', () => { expect((await targetAttachments.resolveContent(importedAttachmentId, { threadId: sourceThread.id })).data.toString()).toBe('portable notes') const importedArtifactId = ((importedItems.find((item) => item.id === 'item_artifact') as { output?: { artifactId?: string } }).output?.artifactId)! expect(await artifacts.get(importedArtifactId)).toBe(artifactContent) + const importedWorkspace = resolve('/Users/bob/Project') expect((await memories.list({ all: true }))[0]).toMatchObject({ - workspace: '/Users/bob/Project', sourceThreadId: sourceThread.id + workspace: process.platform === 'win32' ? importedWorkspace.toLowerCase() : importedWorkspace, + sourceThreadId: sourceThread.id }) await service.rollback(preflight.importId) diff --git a/kun/src/telemetry/agent-observability.test.ts b/kun/src/telemetry/agent-observability.test.ts index a5ae81339..c9b257443 100644 --- a/kun/src/telemetry/agent-observability.test.ts +++ b/kun/src/telemetry/agent-observability.test.ts @@ -44,8 +44,10 @@ describe('AgentObservabilityRecorder', () => { startTimeUnixNano: '0', endTimeUnixNano: '1', durationMs: 1, status: { code: 'OK' }, attributes: {} }) - expect((await stat(join(root, 'private'))).mode & 0o777).toBe(0o700) - expect((await stat(output)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') { + expect((await stat(join(root, 'private'))).mode & 0o777).toBe(0o700) + expect((await stat(output)).mode & 0o777).toBe(0o600) + } }) it('exports turn usage and TTFT without assistant text payloads', async () => { diff --git a/kun/src/tui/options.test.ts b/kun/src/tui/options.test.ts index be81e0435..73c03da64 100644 --- a/kun/src/tui/options.test.ts +++ b/kun/src/tui/options.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { resolve } from 'node:path' import { parseTuiOptions } from './options.js' describe('parseTuiOptions', () => { @@ -27,7 +28,7 @@ describe('parseTuiOptions', () => { runtimeToken: 'flag-token', dataDir: '/tmp/kun-tui-data', dataDirSource: 'argument', - workspace: '/tmp/project', + workspace: resolve('/tmp/project'), threadId: 'thr_1', continueLatest: true, graphPrompt: '实现 TUI Graph 看板', diff --git a/kun/src/tui/pasted-paths.test.ts b/kun/src/tui/pasted-paths.test.ts index 3e0baa369..a833d1af6 100644 --- a/kun/src/tui/pasted-paths.test.ts +++ b/kun/src/tui/pasted-paths.test.ts @@ -1,13 +1,18 @@ import { describe, expect, it } from 'vitest' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' import { parsePastedFilePaths } from './pasted-paths.js' describe('parsePastedFilePaths', () => { it('accepts quoted, escaped, relative, file URL, and multi-file path pastes', () => { + const fileUrlPath = resolve('/tmp/a b.png') expect(parsePastedFilePaths("'/tmp/a b.png'", '/work', '/home/me')).toEqual(['/tmp/a b.png']) expect(parsePastedFilePaths('/tmp/a\\ b.png', '/work', '/home/me')).toEqual(['/tmp/a b.png']) - expect(parsePastedFilePaths('./shot.png', '/work', '/home/me')).toEqual(['/work/shot.png']) - expect(parsePastedFilePaths('~/shot.png', '/work', '/home/me')).toEqual(['/home/me/shot.png']) - expect(parsePastedFilePaths('file:///tmp/a%20b.png', '/work', '/home/me')).toEqual(['/tmp/a b.png']) + expect(parsePastedFilePaths('./shot.png', '/work', '/home/me')).toEqual([resolve('/work', 'shot.png')]) + expect(parsePastedFilePaths('~/shot.png', '/work', '/home/me')).toEqual([resolve('/home/me', 'shot.png')]) + expect(parsePastedFilePaths(pathToFileURL(fileUrlPath).toString(), '/work', '/home/me')).toEqual([ + fileUrlPath + ]) expect(parsePastedFilePaths('/tmp/a.png\u0000/tmp/b.png', '/work', '/home/me')).toEqual([ '/tmp/a.png', '/tmp/b.png' diff --git a/kun/tests/attachment-store.test.ts b/kun/tests/attachment-store.test.ts index 9ec7ed3d1..161d075c0 100644 --- a/kun/tests/attachment-store.test.ts +++ b/kun/tests/attachment-store.test.ts @@ -111,9 +111,11 @@ describe('Attachment store and multimodal input', () => { const attachment = await store.create({ name: 'shot.png', data: png(2, 3), threadId: 'thr_1' }) const root = join(dir, 'attachments') - expect((await stat(root)).mode & 0o777).toBe(0o700) - expect((await stat(join(root, `${attachment.id}.bin`))).mode & 0o777).toBe(0o600) - expect((await stat(join(root, `${attachment.id}.json`))).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') { + expect((await stat(root)).mode & 0o777).toBe(0o700) + expect((await stat(join(root, `${attachment.id}.bin`))).mode & 0o777).toBe(0o600) + expect((await stat(join(root, `${attachment.id}.json`))).mode & 0o777).toBe(0o600) + } }) it('keeps composer leases private, reference-counts duplicate uploads, and rejects foreign release ids', async () => { diff --git a/kun/tests/background-shell-output.test.ts b/kun/tests/background-shell-output.test.ts index c7d4d7dd3..92739f681 100644 --- a/kun/tests/background-shell-output.test.ts +++ b/kun/tests/background-shell-output.test.ts @@ -53,8 +53,10 @@ describe('background-shell-output', () => { await writer.close() const persisted = await readFile(live.output_file, 'utf-8') expect(persisted.startsWith('hello\n')).toBe(true) - expect((await stat(writer.paths.outputDir)).mode & 0o777).toBe(0o700) - expect((await stat(live.output_file)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') { + expect((await stat(writer.paths.outputDir)).mode & 0o777).toBe(0o700) + expect((await stat(live.output_file)).mode & 0o777).toBe(0o600) + } const summary = await readBackgroundShellOutputSummary(live.output_file) expect(summary.truncated).toBe(true) }) diff --git a/kun/tests/delegation-runtime.test.ts b/kun/tests/delegation-runtime.test.ts index 6962b7d4f..b46695ccf 100644 --- a/kun/tests/delegation-runtime.test.ts +++ b/kun/tests/delegation-runtime.test.ts @@ -1509,8 +1509,10 @@ describe('DelegationRuntime', () => { await store.upsert(ChildRunRecord.parse({ ...base, id: 'child_run', status: 'running' })) await store.upsert(ChildRunRecord.parse({ ...base, id: 'child_queued', status: 'queued' })) await store.upsert(ChildRunRecord.parse({ ...base, id: 'child_done', status: 'completed' })) - expect((await stat(join(dir, 'children'))).mode & 0o777).toBe(0o700) - expect((await stat(join(dir, 'children', 'child_run.json'))).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') { + expect((await stat(join(dir, 'children'))).mode & 0o777).toBe(0o700) + expect((await stat(join(dir, 'children', 'child_run.json'))).mode & 0o777).toBe(0o600) + } const runtime = createRuntime({}) const reconciled = await runtime.reconcileOrphanedChildRuns() diff --git a/kun/tests/extension-package.test.ts b/kun/tests/extension-package.test.ts index f91299b2d..5fd36f638 100644 --- a/kun/tests/extension-package.test.ts +++ b/kun/tests/extension-package.test.ts @@ -74,7 +74,9 @@ describe('extension package management', () => { expect((await registry.get('acme.demo'))?.selectedVersion).toBe('1.0.0') expect((await registry.get('acme.demo'))?.previousSelectedVersion).toBe('2.0.0') - await expect(writeFile(join(v1.packagePath, 'tamper.txt'), 'nope')).rejects.toBeDefined() + if (process.platform !== 'win32') { + await expect(writeFile(join(v1.packagePath, 'tamper.txt'), 'nope')).rejects.toBeDefined() + } await manager.setGlobalEnabled('acme.demo', false) expect(await registry.isEnabled('acme.demo')).toBe(false) expect(await registry.publicSnapshot()).toMatchObject({ diff --git a/kun/tests/memory-store.test.ts b/kun/tests/memory-store.test.ts index f923d6406..d8de7a1ec 100644 --- a/kun/tests/memory-store.test.ts +++ b/kun/tests/memory-store.test.ts @@ -57,8 +57,10 @@ describe('Memory store and recall', () => { const memory = await store.create({ content: 'private preference', scope: 'user' }) const root = join(dir, 'memory') - expect((await stat(root)).mode & 0o777).toBe(0o700) - expect((await stat(join(root, `${memory.id}.json`))).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') { + expect((await stat(root)).mode & 0o777).toBe(0o700) + expect((await stat(join(root, `${memory.id}.json`))).mode & 0o777).toBe(0o600) + } }) it('tracks provenance, expiry, confidence decay, and legacy records', async () => { diff --git a/kun/tests/thread-store-doctor-race.test.ts b/kun/tests/thread-store-doctor-race.test.ts index 20337d901..797b9e070 100644 --- a/kun/tests/thread-store-doctor-race.test.ts +++ b/kun/tests/thread-store-doctor-race.test.ts @@ -92,7 +92,11 @@ describe('scanThreadStore replacement detection', () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) - it('reports changed when the path is atomically replaced after handle fstat', async () => { + it('reports changed when the path is atomically replaced after handle fstat', async (testContext) => { + if (process.platform === 'win32') { + testContext.skip() + return + } const root = await mkdtemp(join(tmpdir(), 'kun-thread-store-doctor-race-')) roots.push(root) const thread = createThreadRecord({ diff --git a/kun/tests/thread-store-repair.test.ts b/kun/tests/thread-store-repair.test.ts index 8dce8eb89..4fba23ad2 100644 --- a/kun/tests/thread-store-repair.test.ts +++ b/kun/tests/thread-store-repair.test.ts @@ -79,7 +79,11 @@ describe('JSONL tail repair', () => { expect(await inspectJsonlTail(noPrefix)).toMatchObject({ status: 'invalid', reason: 'no_valid_prefix' }) }) - it('detects an append through an already-open descriptor across replacement', async () => { + it('detects an append through an already-open descriptor across replacement', async (testContext) => { + if (process.platform === 'win32') { + testContext.skip() + return + } const valid = JSON.stringify({ ok: true }) const path = await makeFile(`${valid}\n{"broken":`) const held = await open(path, 'a') diff --git a/package-lock.json b/package-lock.json index ccb20df75..8273eeb58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -693,7 +693,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1606,7 +1605,6 @@ "darwin", "win32" ], - "peer": true, "dependencies": { "@computer-use/default-clipboard-provider": "4.2.0", "@computer-use/libnut": "4.2.0", @@ -2336,7 +2334,6 @@ "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" @@ -3020,7 +3017,6 @@ "resolved": "https://registry.npmmirror.com/@jimp/custom/-/custom-0.22.12.tgz", "integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/core": "^0.22.12" } @@ -3299,7 +3295,6 @@ "resolved": "https://registry.npmmirror.com/@jimp/plugin-blur/-/plugin-blur-1.6.1.tgz", "integrity": "sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/core": "1.6.1", "@jimp/utils": "1.6.1" @@ -3697,7 +3692,6 @@ "resolved": "https://registry.npmmirror.com/@jimp/plugin-resize/-/plugin-resize-1.6.1.tgz", "integrity": "sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/core": "1.6.1", "@jimp/types": "1.6.1", @@ -3749,7 +3743,6 @@ "resolved": "https://registry.npmmirror.com/@jimp/plugin-scale/-/plugin-scale-0.22.12.tgz", "integrity": "sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -3856,7 +3849,6 @@ "resolved": "https://registry.npmmirror.com/@jimp/plugin-blit/-/plugin-blit-0.22.12.tgz", "integrity": "sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -3893,7 +3885,6 @@ "resolved": "https://registry.npmmirror.com/@jimp/plugin-color/-/plugin-color-0.22.12.tgz", "integrity": "sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12", "tinycolor2": "^1.6.0" @@ -3937,7 +3928,6 @@ "resolved": "https://registry.npmmirror.com/@jimp/plugin-crop/-/plugin-crop-0.22.12.tgz", "integrity": "sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -4025,7 +4015,6 @@ "resolved": "https://registry.npmmirror.com/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz", "integrity": "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -4038,7 +4027,6 @@ "resolved": "https://registry.npmmirror.com/@jimp/plugin-rotate/-/plugin-rotate-0.22.12.tgz", "integrity": "sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -5535,7 +5523,6 @@ "integrity": "sha512-7jTed/RirIVsp+lLdLvGzGqF3EBGpnGHGYKOwz6t28V2BIJLAFdUhfEVdWie7xPxQNWK0TP+fPlsqZS0vxfHBg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -5788,7 +5775,6 @@ "integrity": "sha512-EM8woyHDNKLEQ+lWUEoDtA4KrwP6fei/mYX1NxseMzKHHo7LFecx7wk6sovAXZrUvdML/yFBihgiMiO5VIsfkg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -5917,7 +5903,6 @@ "integrity": "sha512-4wajuqnO2X0+LVvsBjW/xk3/tmdb16bNL939QhicAay4YYqXITeV2v3XJsryzmG4L5GkK1yLxvRGk4aLoxWrnA==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -5951,7 +5936,6 @@ "integrity": "sha512-q4RDeWwVrhOL0jJCGRgGxLSdjOYwzQ4h2InURZVhC66433ipcHd6f3bqSOhcXZ4r0sFmMNsuF7aZmUntjWLc7w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", @@ -6517,7 +6501,6 @@ "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6528,7 +6511,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6629,7 +6611,6 @@ "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", @@ -7131,7 +7112,6 @@ "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7611,7 +7591,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -8134,7 +8113,6 @@ "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -8579,7 +8557,6 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -9360,7 +9337,6 @@ "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -9975,9 +9951,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -10751,11 +10727,10 @@ } }, "node_modules/hono": { - "version": "4.12.30", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.30.tgz", - "integrity": "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==", + "version": "4.12.34", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.34.tgz", + "integrity": "sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -10926,7 +10901,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -11088,9 +11062,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -11371,7 +11345,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -13777,7 +13750,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14330,8 +14302,7 @@ "version": "2.2.0", "resolved": "https://registry.npmmirror.com/quickjs-wasi/-/quickjs-wasi-2.2.0.tgz", "integrity": "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/range-parser": { "version": "1.2.1", @@ -14393,7 +14364,6 @@ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.6.tgz", "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14403,7 +14373,6 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.6.tgz", "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -15858,7 +15827,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16077,7 +16045,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16123,9 +16090,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmmirror.com/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "optional": true, @@ -16432,7 +16399,6 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -16526,7 +16492,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16959,7 +16924,6 @@ "resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/packages/create-kun-extension/test/scaffold.test.ts b/packages/create-kun-extension/test/scaffold.test.ts index be5ea6ddf..5bc810972 100644 --- a/packages/create-kun-extension/test/scaffold.test.ts +++ b/packages/create-kun-extension/test/scaffold.test.ts @@ -1,4 +1,5 @@ import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, extname, join, resolve } from 'node:path' @@ -84,11 +85,22 @@ describe('create-kun-extension', () => { template: 'webview' }) - const result = spawnSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['test'], { + const npmCli = process.env.npm_execpath || ( + process.platform === 'win32' + ? join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js') + : '' + ) + const useNodeCli = Boolean(npmCli && existsSync(npmCli)) + const result = spawnSync( + useNodeCli ? process.execPath : process.platform === 'win32' ? 'npm.cmd' : 'npm', + useNodeCli ? [npmCli, 'test'] : ['test'], + { cwd: targetDirectory, encoding: 'utf8', env: { ...process.env, npm_config_audit: 'false', npm_config_fund: 'false' } - }) + } + ) + expect(result.error).toBeUndefined() expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0) const packageJson = JSON.parse(await readFile(join(targetDirectory, 'package.json'), 'utf8')) @@ -112,7 +124,7 @@ describe('create-kun-extension', () => { expect(source).not.toContain('@kun/extension-api') expect(moduleSpecifiers(source).filter((specifier) => !specifier.startsWith('.'))).toEqual([]) } - }) + }, 20_000) }) async function collectJavaScript(directory: string): Promise { diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index ddd101870..fba776431 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -457,7 +457,7 @@ function validateBundledOfficeCli(context) { if (digest !== expected.sha256) { throw new Error(`[after-pack] OfficeCLI digest mismatch for ${targetKey}`) } - if (platform !== 'win32') { + if (platform !== 'win32' && process.platform !== 'win32') { chmodSync(executablePath, 0o755) if ((lstatSync(executablePath).mode & 0o111) === 0) { throw new Error(`[after-pack] OfficeCLI is not executable for ${targetKey}`) diff --git a/scripts/check-production-audit.mjs b/scripts/check-production-audit.mjs index 7858bc9d4..b2c684f0f 100644 --- a/scripts/check-production-audit.mjs +++ b/scripts/check-production-audit.mjs @@ -6,6 +6,9 @@ import { fileURLToPath } from 'node:url' const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') const registry = 'https://registry.npmjs.org' +const npmCliPath = process.env.npm_execpath +const npmCommand = npmCliPath ? process.execPath : 'npm' +const npmPrefixArgs = npmCliPath ? [npmCliPath] : [] const allowedModeratePackages = new Set([ '@computer-use/default-clipboard-provider', '@computer-use/libnut', @@ -42,8 +45,8 @@ console.log( function audit(target) { const result = spawnSync( - 'npm', - ['audit', '--omit=dev', '--json', `--registry=${registry}`], + npmCommand, + [...npmPrefixArgs, 'audit', '--omit=dev', '--json', `--registry=${registry}`], { cwd: target.cwd, encoding: 'utf8', diff --git a/scripts/smoke-windows-installer-migration.ps1 b/scripts/smoke-windows-installer-migration.ps1 index 45b9ab862..b7e784f50 100644 --- a/scripts/smoke-windows-installer-migration.ps1 +++ b/scripts/smoke-windows-installer-migration.ps1 @@ -48,11 +48,21 @@ function Invoke-Installer( ) { $script:currentScenario = $Scenario $argumentText = $Arguments -join ' ' - Write-Host "[$Scenario] Starting installer: $argumentText" - $stopwatch = [Diagnostics.Stopwatch]::StartNew() - $process = Start-Process -FilePath $script:InstallerPath -ArgumentList $Arguments -Wait -PassThru - $stopwatch.Stop() - Write-Host "[$Scenario] Installer exited with $($process.ExitCode) after $([math]::Round($stopwatch.Elapsed.TotalSeconds, 1))s." + $accessViolationExitCode = -1073741819 + $maximumAttempts = 2 + $process = $null + for ($attempt = 1; $attempt -le $maximumAttempts; $attempt += 1) { + Write-Host "[$Scenario] Starting installer (attempt $attempt/$maximumAttempts): $argumentText" + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + $process = Start-Process -FilePath $script:InstallerPath -ArgumentList $Arguments -Wait -PassThru + $stopwatch.Stop() + Write-Host "[$Scenario] Installer exited with $($process.ExitCode) after $([math]::Round($stopwatch.Elapsed.TotalSeconds, 1))s." + if ($process.ExitCode -ne $accessViolationExitCode -or $attempt -eq $maximumAttempts) { + break + } + Write-Warning "[$Scenario] Installer hit Windows access violation 0xC0000005; retrying once after 2 seconds." + Start-Sleep -Seconds 2 + } if ($process.ExitCode -ne $ExpectedExitCode -and (Test-Path -LiteralPath $script:diagnosticPath -PathType Leaf)) { Write-Host "[$Scenario] Installer helper diagnostics:" Write-Host (Get-Content -LiteralPath $script:diagnosticPath -Raw) @@ -60,6 +70,28 @@ function Invoke-Installer( Assert-True ($process.ExitCode -eq $ExpectedExitCode) "Installer exited with $($process.ExitCode), expected $ExpectedExitCode. Arguments: $argumentText" } +function Invoke-Uninstaller( + [string]$Scenario, + [string]$InstallLocation, + [ValidateSet('/currentuser', '/allusers')][string]$Mode +) { + $script:currentScenario = $Scenario + $source = Join-Path $InstallLocation 'Uninstall Kun.exe' + $copy = Join-Path $script:root ('kun-smoke-uninstaller-' + [guid]::NewGuid().ToString('N') + '.exe') + Copy-Item -LiteralPath $source -Destination $copy + try { + # NSIS uninstallers normally launch a temporary child and let the original + # process exit early. Running our own copy with _?= as the final, unquoted + # argument makes -Wait observe the real uninstall lifecycle. + $arguments = @('/S', $Mode, ('_?={0}' -f $InstallLocation)) + Write-Host "[$Scenario] Starting copied uninstaller: $($arguments -join ' ')" + $process = Start-Process -FilePath $copy -ArgumentList $arguments -Wait -PassThru + Assert-True ($process.ExitCode -eq 0) "Uninstaller exited with $($process.ExitCode)." + } finally { + Remove-Item -LiteralPath $copy -Force -ErrorAction SilentlyContinue + } +} + function Find-KunRegistration( [string]$ExpectedLocation, [ValidateSet('HKCU', 'HKLM')][string]$Hive = 'HKCU' @@ -282,10 +314,7 @@ try { $script:currentScenario = 'Unicode packaged CLI smoke' & node (Join-Path $PSScriptRoot 'smoke-packaged-cli.cjs') '--resources' (Join-Path $unicodeTarget 'resources') Assert-True ($LASTEXITCODE -eq 0) 'The packaged CLI did not run from the Unicode install directory.' - $script:currentScenario = 'Unicode current-user uninstall' - $unicodeUninstaller = Join-Path $unicodeTarget 'Uninstall Kun.exe' - $unicodeUninstall = Start-Process -FilePath $unicodeUninstaller -ArgumentList @('/S', '/currentuser') -Wait -PassThru - Assert-True ($unicodeUninstall.ExitCode -eq 0) "Unicode smoke uninstaller exited with $($unicodeUninstall.ExitCode)." + Invoke-Uninstaller 'Unicode current-user uninstall' $unicodeTarget '/currentuser' Assert-PathEntryRemoved $unicodeTarget Assert-NoKunShortcuts 'CurrentUser' @@ -460,10 +489,7 @@ try { $automaticUpdateDiagnostics = Get-Content -LiteralPath $diagnosticPath -Raw Assert-True ($automaticUpdateDiagnostics -match [regex]::Escape("source=$machineTarget")) 'The automatic update did not validate the running all-users source.' - $script:currentScenario = 'all-users uninstall' - $machineUninstaller = Join-Path $machineTarget 'Uninstall Kun.exe' - $machineUninstall = Start-Process -FilePath $machineUninstaller -ArgumentList @('/S', '/allusers') -Wait -PassThru - Assert-True ($machineUninstall.ExitCode -eq 0) "All-users smoke uninstaller exited with $($machineUninstall.ExitCode)." + Invoke-Uninstaller 'all-users uninstall' $machineTarget '/allusers' Assert-PathEntryRemoved $machineTarget foreach ($sentinel in $sentinels) { Assert-True (Test-Path -LiteralPath $sentinel) "All-users uninstall removed a user-data sentinel: $sentinel" diff --git a/src/main/app-identity.test.ts b/src/main/app-identity.test.ts index c0329cc16..b398515cf 100644 --- a/src/main/app-identity.test.ts +++ b/src/main/app-identity.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' const setName = vi.fn() const setAppUserModelId = vi.fn() @@ -42,7 +43,7 @@ describe('app identity bootstrap', () => { runtimeFlavor: 'development' }) expect(setName).toHaveBeenCalledWith('kun-dv') - expect(setPath).toHaveBeenCalledWith('userData', '/app-data/kun-dv') + expect(setPath).toHaveBeenCalledWith('userData', join('/app-data', 'kun-dv')) }) it('does not call app.setAppUserModelId (caller responsibility on win32)', async () => { diff --git a/src/main/data-migration/application-state-migration.test.ts b/src/main/data-migration/application-state-migration.test.ts index 920cff095..f04edbd2a 100644 --- a/src/main/data-migration/application-state-migration.test.ts +++ b/src/main/data-migration/application-state-migration.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { sep } from 'node:path' import { normalizeAppSettings, type AppSettingsV1 } from '../../shared/app-settings' import { applyPortableSettingsMigration, @@ -87,7 +88,7 @@ describe('application state migration', () => { workspaceRoot: 'D:\\Missing', threadId: 'not-a-declared-write-thread-field', filePaths: [ - '/Users/bob/Project/drafts/chapter.md', + `/Users/bob/Project${sep}drafts${sep}chapter.md`, 'D:\\Missing\\outside.md' ] }) diff --git a/src/main/ipc/register-app-ipc-handlers.test.ts b/src/main/ipc/register-app-ipc-handlers.test.ts index f3e70b31a..17db07650 100644 --- a/src/main/ipc/register-app-ipc-handlers.test.ts +++ b/src/main/ipc/register-app-ipc-handlers.test.ts @@ -1,6 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { EventEmitter } from 'node:events' -import { existsSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { @@ -1183,6 +1191,7 @@ describe('registerAppIpcHandlers', () => { }, null, 2) try { await import('node:fs/promises').then(({ mkdir }) => mkdir(workspace)) + const canonicalWorkspace = realpathSync.native(workspace) registerAppIpcHandlers(registerOptions({ onKunProjectConfigChanged })) const written = await handlers.get('kun:project-config:write')?.({}, { @@ -1197,7 +1206,7 @@ describe('registerAppIpcHandlers', () => { exists: true }) expect(onKunProjectConfigChanged).toHaveBeenCalledWith( - expect.stringContaining('/workspace/.kun/project.json'), + join(canonicalWorkspace, '.kun', 'project.json'), content ) await expect(handlers.get('kun:project-config:read')?.({}, { workspaceRoot: workspace })) @@ -1223,6 +1232,7 @@ describe('registerAppIpcHandlers', () => { }) try { await import('node:fs/promises').then(({ mkdir }) => mkdir(workspace)) + const canonicalWorkspace = realpathSync.native(workspace) registerAppIpcHandlers(registerOptions({ store: store as never, applySettingsPatch })) await handlers.get('kun:project-config:write')?.({}, { workspaceRoot: workspace, @@ -1287,7 +1297,7 @@ describe('registerAppIpcHandlers', () => { cancelId: 1 })) expect(current.agents.kun.projectConfig.grants).toEqual([ - expect.objectContaining({ workspaceRoot: expect.stringContaining('/workspace') }) + expect.objectContaining({ workspaceRoot: canonicalWorkspace }) ]) writeFileSync(join(workspace, '.kun', 'project.json'), JSON.stringify({ diff --git a/src/main/packaged-install-health.test.ts b/src/main/packaged-install-health.test.ts index 730957d78..95db4c3c3 100644 --- a/src/main/packaged-install-health.test.ts +++ b/src/main/packaged-install-health.test.ts @@ -2,7 +2,10 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { inspectPackagedInstallHealth } from './packaged-install-health' +import { + inspectPackagedInstallHealth, + resolveInstallHealthFileStat +} from './packaged-install-health' describe('inspectPackagedInstallHealth', () => { let directory = '' @@ -51,4 +54,19 @@ describe('inspectPackagedInstallHealth', () => { resourcesPath: 'missing-resources' })).toEqual({ ok: true }) }) + + it('uses Electron original-fs so app.asar is checked as a physical file', () => { + const rawStat = resolveInstallHealthFileStat((id) => { + expect(id).toBe('original-fs') + return { + statSync: () => ({ + isFile: () => true, + size: 42 + }) + } + }) + + expect(rawStat('resources/app.asar').isFile()).toBe(true) + expect(rawStat('resources/app.asar').size).toBe(42) + }) }) diff --git a/src/main/packaged-install-health.ts b/src/main/packaged-install-health.ts index 0fa0747fc..7b2ce8156 100644 --- a/src/main/packaged-install-health.ts +++ b/src/main/packaged-install-health.ts @@ -1,4 +1,5 @@ -import { statSync } from 'node:fs' +import { statSync as nodeStatSync } from 'node:fs' +import { createRequire } from 'node:module' import { join } from 'node:path' export type PackagedInstallHealth = @@ -11,10 +12,40 @@ type PackagedInstallHealthInput = { resourcesPath: string } +type FileStat = { + isFile(): boolean + size: number | bigint +} + +type FileStatSync = (path: string) => FileStat +type ModuleLoader = (id: string) => unknown + +const requireFromHere = createRequire(import.meta.url) + +/** + * Electron virtualizes `node:fs` access to an ASAR archive. In a packaged app, + * statSync(resources/app.asar) therefore describes the mounted archive root as + * a directory instead of the physical archive file. `original-fs` bypasses the + * ASAR layer; plain Node (including unit tests) falls back to its already-raw fs. + */ +export function resolveInstallHealthFileStat(loadModule: ModuleLoader = requireFromHere): FileStatSync { + try { + const rawFs = loadModule('original-fs') as { statSync?: FileStatSync } + if (typeof rawFs?.statSync === 'function') { + return (path) => rawFs.statSync!(path) + } + } catch { + // `original-fs` is an Electron built-in and is unavailable in plain Node. + } + return (path) => nodeStatSync(path) +} + +const rawStatSync = resolveInstallHealthFileStat() + function isNonEmptyFile(path: string): boolean { try { - const stat = statSync(path) - return stat.isFile() && stat.size > 0 + const stat = rawStatSync(path) + return stat.isFile() && stat.size > 0n } catch { return false } diff --git a/src/main/packaging-config.test.ts b/src/main/packaging-config.test.ts index 960dc3ce0..5144169b8 100644 --- a/src/main/packaging-config.test.ts +++ b/src/main/packaging-config.test.ts @@ -359,7 +359,9 @@ describe('electron-builder Kun packaging', () => { } expect(() => afterPack._internals.validateBundledOfficeCli(context)).not.toThrow() - expect(statSync(join(officeRoot, 'officecli')).mode & 0o111).not.toBe(0) + if (process.platform !== 'win32') { + expect(statSync(join(officeRoot, 'officecli')).mode & 0o111).not.toBe(0) + } writeFileSync(join(officeRoot, 'officecli.exe'), 'wrong architecture') expect(() => afterPack._internals.validateBundledOfficeCli(context)).toThrow( @@ -495,6 +497,15 @@ describe('electron-builder Kun packaging', () => { expect(installerScript).toContain('Var /GLOBAL KunInstallerCandidateExplicit') expect(installerScript).toContain('Function KunSelectAutomaticUpdateMode') expect(installerScript).toContain('!insertmacro kunRunMigrationHelper ResolveUpdateScope') + expect(installerScript).toContain( + 'Automatic update selected the only registered current-user ${PRODUCT_NAME} installation.' + ) + expect(installerScript).toContain( + 'Automatic update selected the only registered all-users ${PRODUCT_NAME} installation.' + ) + expect(installerScript).toContain( + 'Automatic update source marker is unavailable with registrations in both scopes; keeping the requested install mode.' + ) expect(installerScript).toContain('KUN_INSTALLER_CURRENT_USER_SOURCE') expect(installerScript).toContain('KUN_INSTALLER_ALL_USERS_SOURCE') expect(installerScript).toContain('KUN_INSTALLER_CANDIDATE_EXPLICIT') @@ -509,9 +520,23 @@ describe('electron-builder Kun packaging', () => { ) expect(installerScript).toContain('Function KunReadMigrationResult') expect(installerScript).toContain('IfErrors KunMigrationResultMissing') + expect(installerScript).toContain('-ResultPath "$KunInstallerResultPath"') expect(installerScript).toContain('Function KunResolveRegisteredSource') expect(installerScript).toContain('!insertmacro kunRunMigrationHelper ResolveSource') expect(installerScript).toContain('KUN_INSTALLER_UNINSTALL_STRING') + expect(installerScript).toContain('!macro kunSetEnvironmentFromRegister NAME REGISTER') + expect(installerScript).toContain( + '!insertmacro kunSetEnvironmentFromRegister "KUN_INSTALLER_UNINSTALL_STRING" $R9' + ) + expect(installerScript).toContain( + '!insertmacro kunSetEnvironmentFromRegister "KUN_INSTALLER_CURRENT_USER_UNINSTALL_STRING" $R1' + ) + expect(installerScript).toContain( + '!insertmacro kunSetEnvironmentFromRegister "KUN_INSTALLER_ALL_USERS_UNINSTALL_STRING" $R3' + ) + expect(installerScript).not.toContain( + 'SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_UNINSTALL_STRING", "$R9")' + ) expect(installerScript).toContain('${if} $KunInstallerSnapshotMode != $installMode') expect(installerScript).toContain('${andIf} $installMode != "all"') expect(installerScript).not.toContain('KUN_INSTALLER_RESULT') diff --git a/src/main/runtime-data-dir-preserving-migration.test.ts b/src/main/runtime-data-dir-preserving-migration.test.ts index b7ee102ca..376ed422f 100644 --- a/src/main/runtime-data-dir-preserving-migration.test.ts +++ b/src/main/runtime-data-dir-preserving-migration.test.ts @@ -434,10 +434,12 @@ describe('history-preserving Kun Runtime migration', () => { expect(result.status).toBe('completed') expect(await readFile(targetLicense, 'utf8')).toBe('immutable package') - expect((await stat(sourcePackage)).mode & 0o777).toBe(0o555) - expect((await stat(targetPackage)).mode & 0o777).toBe(0o555) - expect((await stat(sourceLicense)).mode & 0o777).toBe(0o444) - expect((await stat(targetLicense)).mode & 0o777).toBe(0o444) + if (process.platform !== 'win32') { + expect((await stat(sourcePackage)).mode & 0o777).toBe(0o555) + expect((await stat(targetPackage)).mode & 0o777).toBe(0o555) + expect((await stat(sourceLicense)).mode & 0o777).toBe(0o444) + expect((await stat(targetLicense)).mode & 0o777).toBe(0o444) + } await chmod(sourcePackage, 0o755) await chmod(targetPackage, 0o755) }) diff --git a/src/main/runtime/kun-adapter.test.ts b/src/main/runtime/kun-adapter.test.ts index bceda3fc4..4ed7004ba 100644 --- a/src/main/runtime/kun-adapter.test.ts +++ b/src/main/runtime/kun-adapter.test.ts @@ -76,6 +76,20 @@ function listen(handler: RequestHandler): Promise { }) } +async function reserveUnusedPort(): Promise { + const candidate = createServer() + const port = await new Promise((resolve, reject) => { + candidate.once('error', reject) + candidate.listen(0, '127.0.0.1', () => { + resolve((candidate.address() as AddressInfo).port) + }) + }) + await new Promise((resolve, reject) => { + candidate.close((error) => error ? reject(error) : resolve()) + }) + return port +} + afterEach(async () => { const current = server server = null @@ -173,15 +187,16 @@ describe('runtimeRequestViaHost', () => { res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify({ ok: true, retried: true })) }) + const stalePort = await reserveUnusedPort() let ensureCalls = 0 const response = await runtimeRequestViaHost( - settingsForPort(1), + settingsForPort(stalePort), '/v1/threads', { method: 'POST', body: JSON.stringify({ title: 'hello' }) }, async () => { ensureCalls += 1 - return ensureCalls === 1 ? settingsForPort(1) : settingsForPort(port) + return ensureCalls === 1 ? settingsForPort(stalePort) : settingsForPort(port) } ) diff --git a/src/main/services/git-checkpoint-service.test.ts b/src/main/services/git-checkpoint-service.test.ts index 06113ba13..81b981ec1 100644 --- a/src/main/services/git-checkpoint-service.test.ts +++ b/src/main/services/git-checkpoint-service.test.ts @@ -44,6 +44,8 @@ describe('git checkpoint service', () => { execFileSync('git', ['init', '-b', 'main', unbornRepo], { stdio: 'pipe' }) execFileSync('git', ['-C', unbornRepo, 'config', 'user.email', 'test@example.com'], { stdio: 'pipe' }) execFileSync('git', ['-C', unbornRepo, 'config', 'user.name', 'Test'], { stdio: 'pipe' }) + execFileSync('git', ['-C', unbornRepo, 'config', 'core.autocrlf', 'false'], { stdio: 'pipe' }) + execFileSync('git', ['-C', unbornRepo, 'config', 'core.eol', 'lf'], { stdio: 'pipe' }) await writeFile(join(unbornRepo, 'staged.txt'), 'staged checkpoint\n') execFileSync('git', ['-C', unbornRepo, 'add', 'staged.txt'], { stdio: 'pipe' }) diff --git a/src/main/services/prototype-embed-registry.test.ts b/src/main/services/prototype-embed-registry.test.ts index 57c8e6bc1..ef67562bc 100644 --- a/src/main/services/prototype-embed-registry.test.ts +++ b/src/main/services/prototype-embed-registry.test.ts @@ -1,4 +1,5 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -47,7 +48,7 @@ describe('prototype embed registry', () => { expect(result.ok).toBe(true) if (!result.ok) return - expect(result.absolutePath).toBe(join(workspace, relativePath)) + expect(result.absolutePath).toBe(await realpath(join(workspace, relativePath))) expect(isAuthorizedPrototypeFileUrl(result.fileUrl)).toBe(true) }) diff --git a/src/main/services/workspace-paths.ts b/src/main/services/workspace-paths.ts index aa3b22dd4..ae618bef9 100644 --- a/src/main/services/workspace-paths.ts +++ b/src/main/services/workspace-paths.ts @@ -195,14 +195,9 @@ export async function resolveTargetPathWithinWorkspace(rawPath: string, workspac } const direct = resolve(expanded) - if (isWithinWorkspace(workspacePath, direct)) { - return enforceProspectiveWorkspaceBoundary(workspacePath, direct) - } - if (await pathExists(direct)) { - const canonicalTarget = await canonicalPath(direct) - if (isWithinWorkspace(workspacePath, canonicalTarget)) { - return canonicalTarget - } + const canonicalTarget = await canonicalPathThroughExistingParent(direct) + if (isWithinWorkspace(workspacePath, canonicalTarget)) { + return canonicalTarget } throw new Error('Path must stay within the selected workspace.') } diff --git a/src/main/services/write-infographic-service.test.ts b/src/main/services/write-infographic-service.test.ts index 6f7531b25..7630f8828 100644 --- a/src/main/services/write-infographic-service.test.ts +++ b/src/main/services/write-infographic-service.test.ts @@ -1,4 +1,5 @@ import { mkdtempSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -91,7 +92,7 @@ describe('write infographic service', () => { expect(result.ok).toBe(true) if (!result.ok) return expect(result.relativePath).toMatch(/^\.\.\/img\/infographic-\d{14}-[0-9a-f]{4}\.png$/) - expect(result.absolutePath).toBe(join(workspace, 'img', result.fileName)) + expect(result.absolutePath).toBe(await realpath(join(workspace, 'img', result.fileName))) expect(existsSync(result.absolutePath)).toBe(true) expect(readFileSync(result.absolutePath, 'utf8')).toBe('fake-png-bytes') @@ -114,7 +115,7 @@ describe('write infographic service', () => { expect(result.ok).toBe(true) if (!result.ok) return expect(result.relativePath).toMatch(/^img\/infographic-\d{14}-[0-9a-f]{4}\.png$/) - expect(result.absolutePath).toBe(join(workspace, 'img', result.fileName)) + expect(result.absolutePath).toBe(await realpath(join(workspace, 'img', result.fileName))) }) it('prefers an explicit defaultSize over the portrait default', async () => { @@ -298,7 +299,7 @@ describe('write infographic service', () => { expect(result.ok).toBe(true) if (!result.ok) return expect(result.relativePath).toMatch(/^\.\.\/\.\.\/img\/design-\d{14}-[0-9a-f]{4}\.png$/) - expect(result.absolutePath).toBe(join(workspace, '.kunsdd', 'img', result.fileName)) + expect(result.absolutePath).toBe(await realpath(join(workspace, '.kunsdd', 'img', result.fileName))) expect(existsSync(result.absolutePath)).toBe(true) }) diff --git a/src/main/windows-installer-migration.test.ts b/src/main/windows-installer-migration.test.ts index 6a1f4ebb1..3fa72a9a9 100644 --- a/src/main/windows-installer-migration.test.ts +++ b/src/main/windows-installer-migration.test.ts @@ -16,6 +16,7 @@ import { join, parse } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' const helperPath = join(process.cwd(), 'build/windows-installer-migration.ps1') +const smokePath = join(process.cwd(), 'scripts/smoke-windows-installer-migration.ps1') const windowsOnly = process.platform === 'win32' ? describe : describe.skip const tempRoots: string[] = [] @@ -137,6 +138,34 @@ afterEach(() => { } }) +describe('Windows installer migration ACL contract', () => { + it('uses the Windows filesystem ACL API without the optional PowerShell security module', () => { + const script = readFileSync(helperPath, 'utf8') + + expect(script).not.toMatch(/\b(?:Get|Set)-Acl\b/u) + expect(script).toContain('[IO.Directory]::GetAccessControl') + expect(script).toContain('[IO.Directory]::SetAccessControl') + expect(script).toContain('[IO.File]::SetAccessControl') + }) + + it('waits for the real NSIS uninstall lifecycle before starting another installer', () => { + const script = readFileSync(smokePath, 'utf8') + + expect(script).toContain("$arguments = @('/S', $Mode, ('_?={0}' -f $InstallLocation))") + expect(script).toContain('Start-Process -FilePath $copy -ArgumentList $arguments -Wait -PassThru') + expect(script).not.toMatch(/Start-Process -FilePath \$(?:unicode|machine)Uninstaller/u) + }) + + it('retries only a Windows access violation and never more than once', () => { + const script = readFileSync(smokePath, 'utf8') + + expect(script).toContain('$accessViolationExitCode = -1073741819') + expect(script).toContain('$maximumAttempts = 2') + expect(script).toContain('$process.ExitCode -ne $accessViolationExitCode') + expect(script).toContain('retrying once after 2 seconds') + }) +}) + windowsOnly('Windows installer migration helper', () => { it('validates the installed application payload before PATH is updated', () => { const target = join(makeTempRoot(), 'Kun') diff --git a/src/renderer/src/components/design/DesignSidebar.tsx b/src/renderer/src/components/design/DesignSidebar.tsx index 1a26f1d84..24314c7b9 100644 --- a/src/renderer/src/components/design/DesignSidebar.tsx +++ b/src/renderer/src/components/design/DesignSidebar.tsx @@ -1,11 +1,4 @@ -import { - useEffect, - useMemo, - useRef, - useState, - type FormEvent, - type ReactElement -} from 'react' +import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactElement } from 'react' import { FilePlus2, FolderPlus, @@ -284,6 +277,7 @@ export function DesignSidebar({ } const runningDesignThreadIds = useMemo(() => { + void chatThreads const registry = readDesignThreadRegistry() return new Set(Object.values(registry.workspaces).flatMap((record) => record.threadIds)) }, [chatThreads]) diff --git a/src/renderer/src/graph/graph-store.ts b/src/renderer/src/graph/graph-store.ts index ef81d408f..8bfbe1ac9 100644 --- a/src/renderer/src/graph/graph-store.ts +++ b/src/renderer/src/graph/graph-store.ts @@ -2,10 +2,7 @@ import { create } from 'zustand' import type { RuntimeChildEventPayload } from '../agent/types' import { graphRuntimeClient } from './graph-runtime-client' import { steerGraphSourceTurn } from './graph-source-turn-steering' -import { - createGraphLeadWakeAction, - mergeGraphRunSnapshots -} from './graph-supervision-store' +import { createGraphLeadWakeAction, mergeGraphRunSnapshots } from './graph-supervision-store' import { graphChildRuntimeFromEvent, mergeGraphChildDiagnostics, diff --git a/src/renderer/src/locales/hi/common.json b/src/renderer/src/locales/hi/common.json index eb05e28a9..9bdc97dbe 100644 --- a/src/renderer/src/locales/hi/common.json +++ b/src/renderer/src/locales/hi/common.json @@ -470,6 +470,8 @@ "designPrototypePlayUnavailable": "प्रोटोटाइप चलाने से पहले कम से कम एक स्क्रीन बनाएं", "designPrototypeBack": "वापस", "designPrototypeClose": "प्रोटोटाइप बंद करें", + "designPrototypeOpenBrowser": "ब्राउज़र में खोलें", + "designPrototypeOpenBrowserFailed": "प्रोटोटाइप को ब्राउज़र में नहीं खोला जा सका।", "designPrototypeViewportMode": "प्रोटोटाइप व्यूपोर्ट", "designPrototypeViewportWebDetail": "{{width}} x {{height}} web prototype", "designPrototypeViewportAppDetail": "{{width}} x {{height}} phone prototype", diff --git a/src/renderer/src/locales/ja/common.json b/src/renderer/src/locales/ja/common.json index 658c02f3c..bdd78cf75 100644 --- a/src/renderer/src/locales/ja/common.json +++ b/src/renderer/src/locales/ja/common.json @@ -470,6 +470,8 @@ "designPrototypePlayUnavailable": "プロトタイプを再生する前に少なくとも 1 つの画面を作成する", "designPrototypeBack": "戻る", "designPrototypeClose": "プロトタイプを閉じる", + "designPrototypeOpenBrowser": "ブラウザーで開く", + "designPrototypeOpenBrowserFailed": "ブラウザーでプロトタイプを開けませんでした。", "designPrototypeViewportMode": "プロトタイプのビューポート", "designPrototypeViewportWebDetail": "{{width}} x {{height}} web prototype", "designPrototypeViewportAppDetail": "{{width}} x {{height}} phone prototype", diff --git a/src/renderer/src/locales/ko/common.json b/src/renderer/src/locales/ko/common.json index 9b17a10f4..27b244214 100644 --- a/src/renderer/src/locales/ko/common.json +++ b/src/renderer/src/locales/ko/common.json @@ -470,6 +470,8 @@ "designPrototypePlayUnavailable": "프로토타입을 재생하기 전에 화면을 하나 이상 생성하세요.", "designPrototypeBack": "뒤로", "designPrototypeClose": "프로토타입 닫기", + "designPrototypeOpenBrowser": "브라우저에서 열기", + "designPrototypeOpenBrowserFailed": "브라우저에서 프로토타입을 열 수 없습니다.", "designPrototypeViewportMode": "프로토타입 뷰포트", "designPrototypeViewportWebDetail": "{{width}} x {{height}} web prototype", "designPrototypeViewportAppDetail": "{{width}} x {{height}} phone prototype", diff --git a/src/renderer/src/locales/ru/common.json b/src/renderer/src/locales/ru/common.json index cedb898e9..9738a5ac7 100644 --- a/src/renderer/src/locales/ru/common.json +++ b/src/renderer/src/locales/ru/common.json @@ -470,6 +470,8 @@ "designPrototypePlayUnavailable": "Прежде чем играть в прототип, создайте хотя бы один экран.", "designPrototypeBack": "Назад", "designPrototypeClose": "Закрыть прототип", + "designPrototypeOpenBrowser": "Открыть в браузере", + "designPrototypeOpenBrowserFailed": "Не удалось открыть прототип в браузере.", "designPrototypeViewportMode": "Окно просмотра прототипа", "designPrototypeViewportWebDetail": "{{width}} x {{height}} web prototype", "designPrototypeViewportAppDetail": "{{width}} x {{height}} phone prototype", diff --git a/src/renderer/src/locales/th/common.json b/src/renderer/src/locales/th/common.json index 7549d0def..1c4b2bd99 100644 --- a/src/renderer/src/locales/th/common.json +++ b/src/renderer/src/locales/th/common.json @@ -470,6 +470,8 @@ "designPrototypePlayUnavailable": "สร้างอย่างน้อยหนึ่งหน้าจอก่อนเล่นต้นแบบ", "designPrototypeBack": "กลับ", "designPrototypeClose": "ปิดต้นแบบ", + "designPrototypeOpenBrowser": "เปิดในเบราว์เซอร์", + "designPrototypeOpenBrowserFailed": "ไม่สามารถเปิดต้นแบบในเบราว์เซอร์ได้", "designPrototypeViewportMode": "วิวพอร์ตต้นแบบ", "designPrototypeViewportWebDetail": "ต้นแบบเว็บ {{width}} x {{height}}", "designPrototypeViewportAppDetail": "ต้นแบบโทรศัพท์ {{width}} x {{height}}", From 734cfacbe8bf19f56ea451f27e498ca816cb5b51 Mon Sep 17 00:00:00 2001 From: xingyu Date: Tue, 4 Aug 2026 13:08:51 +0800 Subject: [PATCH 5/6] docs(release): add v0.2.35 release notes (#1088) --- release/release-v0.2.35.md | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 release/release-v0.2.35.md diff --git a/release/release-v0.2.35.md b/release/release-v0.2.35.md new file mode 100644 index 000000000..afbf9b1ee --- /dev/null +++ b/release/release-v0.2.35.md @@ -0,0 +1,41 @@ +# Kun v0.2.35 + +v0.2.35 重点加固 Windows 安装、升级和稳定版发版链路。旧版本、不同安装范围、自定义路径和带引号卸载命令现在能更可靠地迁移;Windows 正式构建增加签名与安装器校验。Agent Graph 的结果评审与监督并发也更稳定,同时补齐模型路由多语言内容、设置回退和生产依赖安全更新。 + +### Windows 安装、更新与签名 + +- Windows 正式构建支持代码签名和安装器校验;签名缺失、文件不匹配或安装器配置异常会在发布前失败,不再生成不可验证的正式制品。 +- 安装迁移覆盖 current-user、all-users、旧产品名、自定义及 Unicode 路径、带引号卸载命令和应用内自动更新,能够从有效注册信息恢复真实旧安装源并选择正确的更新范围。 +- 卸载器验证会等待真实 NSIS 卸载生命周期结束,再开始新安装;迁移 helper 使用明确结果文件和安全的参数传递,避免父子进程状态或引号展开丢失。 +- Windows 文件保护与恢复改用系统 ACL API,不依赖可选 PowerShell 安全模块;无法验证的来源、重解析点、受保护根目录和非空冲突目标继续 fail-closed。 +- 打包后健康检查会读取物理 `app.asar`,并验证 CLI、Runtime、Extension、注册表、快捷方式和 PATH,降低安装成功但载荷不完整的风险。 + +### Agent Graph 稳定性 + +- 子代理结果已经持久化、但监督通知与状态更新发生并发冲突时,结果会继续保持可评审状态并等待协调,不再被误判为不可恢复的交付失败。 +- 补充 Graph 调度、监督、评审和恢复的确定性活性测试,并在 Linux、Windows 和 macOS 原生环境中验证平台行为。 +- 调整 Graph 模块边界统计和测试隔离,减少由测试文件、文件系统时序或平台差异造成的误报。 + +### 模型设置与多语言 + +- 补充模型路由相关的多语言文案,使路由设置和状态在各语言界面中保持一致。 +- Graph 设置读取增加兼容回退,旧设置或缺失字段不会导致工作台初始化异常。 +- 补齐 Design 浏览器在日语、韩语、俄语、泰语和印地语中的缺失文案。 + +### 跨平台发布与依赖安全 + +- 发布检查现在区分 Windows、macOS 和 Linux 的路径、权限、文件锁和可执行文件语义;POSIX 专属断言只在 POSIX 环境执行,同时保留 Windows 原生覆盖。 +- 加固 Linux AppImage、macOS x64/arm64、Windows NSIS、standalone TUI、Extension、OCR、FFmpeg 和 Runtime migration 的打包后冒烟与 native evidence。 +- 生产依赖审计改为通过当前 npm CLI 跨平台执行,并升级 `fast-uri`、`ip-address`、`undici` 和 `hono` 等存在安全公告的依赖版本。 +- Windows 原生安装迁移门禁仅对安装器启动前的系统访问冲突执行一次有界重试;业务错误和其他退出码仍会立即阻止发布。 + +### 升级说明 + +- 从 `v0.2.34` 升级无需手动迁移普通会话、工作区、Graph 或 Provider 配置。 +- Windows 用户建议在升级前关闭正在运行的 Kun;安装器会自动识别受支持的旧注册、安装范围和路径,并保留迁移流程允许的用户内容。 +- 从 current-user 切换到 all-users 安装时可能需要管理员权限;迁移完成后安装器会清理经过验证的旧注册、快捷方式和 PATH 项。 +- 如果 Windows 安装目录包含无法识别的文件、重解析点或与目标目录冲突的内容,安装器会停止并保留恢复信息,而不会执行猜测性删除。 + +### 完整变更 + +https://github.com/KunAgent/Kun/compare/v0.2.34...v0.2.35 From 98ac6593ba4d7cb5bfa802dcfd99e736a1fc4169 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Tue, 4 Aug 2026 18:38:01 +0800 Subject: [PATCH 6/6] fix(release): allow unsigned Windows artifacts --- .github/workflows/release.yml | 18 ----------------- electron-builder.config.cjs | 38 ----------------------------------- scripts/release-win.ps1 | 25 ----------------------- 3 files changed, 81 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08f0f065c..09ece5bee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -328,11 +328,6 @@ jobs: DEEPSEEK_GUI_UPDATE_CHANNEL: stable RELEASE_CHANNEL: stable CSC_IDENTITY_AUTO_DISCOVERY: 'false' - KUN_REQUIRE_WINDOWS_SIGNING: '1' - WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} - WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} - WIN_CSC_PUBLISHER_NAME: ${{ secrets.WIN_CSC_PUBLISHER_NAME }} - WIN_CSC_TIMESTAMP_SERVER: 'http://timestamp.digicert.com' steps: - name: Check out merge commit uses: actions/checkout@v4 @@ -374,19 +369,6 @@ jobs: - name: Build Windows installer run: npm run dist:win - - name: Verify Windows code signatures - shell: pwsh - run: | - $installer = @(Get-ChildItem -Path dist -Filter 'Kun-*-win-x64.exe' -File) - if ($installer.Count -ne 1) { - throw "Expected exactly one Windows installer, found $($installer.Count)." - } - & powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\verify-windows-signing.ps1 ` - -FilePath $installer.FullName, (Join-Path $PWD 'dist\win-unpacked\Kun.exe') - if ($LASTEXITCODE -ne 0) { - throw "Windows code-signing verification failed with exit code $LASTEXITCODE." - } - - name: Smoke Windows installer migration shell: pwsh run: npm run smoke:windows-installer-migration diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index ca6370d9f..13f5cf8ed 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -49,28 +49,6 @@ const hasExplicitMacSigningIdentity = Boolean( process.env.MAC_SIGN === '1' ) -const requireWindowsCodeSigning = process.env.KUN_REQUIRE_WINDOWS_SIGNING === '1' -const hasWindowsSigningCredential = Boolean( - process.env.WIN_CSC_LINK?.trim() || - process.env.CSC_LINK?.trim() -) -const windowsPublisherName = (process.env.WIN_CSC_PUBLISHER_NAME || '').trim() -const windowsTimestampServer = ( - process.env.WIN_CSC_TIMESTAMP_SERVER || 'http://timestamp.digicert.com' -).trim() || 'http://timestamp.digicert.com' - -if (requireWindowsCodeSigning && !hasWindowsSigningCredential) { - throw new Error( - 'KUN_REQUIRE_WINDOWS_SIGNING=1 requires WIN_CSC_LINK (or CSC_LINK).' - ) -} - -if (requireWindowsCodeSigning && !windowsPublisherName) { - throw new Error( - 'KUN_REQUIRE_WINDOWS_SIGNING=1 requires WIN_CSC_PUBLISHER_NAME to match the signing certificate subject.' - ) -} - const hasNotaryToolCredentials = Boolean( process.env.APPLE_API_KEY_ID && process.env.APPLE_API_ISSUER && @@ -295,22 +273,6 @@ module.exports = { // the desktop render crisp icons at small sizes (#222). Regenerate with: // npx --yes png2icons src/asset/img/kun_mac.png build/icon -icowe -bc icon: './build/icon.ico', - // Release scripts set KUN_REQUIRE_WINDOWS_SIGNING=1. Keep developer and - // unsigned CI builds available, but make a release fail before publishing - // if the code-signing certificate cannot be used. - ...(requireWindowsCodeSigning - ? { - forceCodeSigning: true, - signtoolOptions: { - // electron-updater reads publisherName from app-update.yml when it - // verifies downloaded installers. The timestamp settings apply to - // both modern and legacy Authenticode signatures. - publisherName: windowsPublisherName, - rfc3161TimeStampServer: windowsTimestampServer, - timeStampServer: windowsTimestampServer - } - } - : {}), target: [{ target: 'nsis', arch: ['x64'] }] }, nsis: { diff --git a/scripts/release-win.ps1 b/scripts/release-win.ps1 index 3bc21aba3..b03b06e9a 100644 --- a/scripts/release-win.ps1 +++ b/scripts/release-win.ps1 @@ -51,20 +51,6 @@ function Require-Command([string]$Name) { } } -function Assert-WindowsSigningConfiguration { - $hasCredential = -not [string]::IsNullOrWhiteSpace($env:WIN_CSC_LINK) -or - -not [string]::IsNullOrWhiteSpace($env:CSC_LINK) - if (-not $hasCredential) { - Write-Err 'Windows release signing requires WIN_CSC_LINK (or CSC_LINK).' - exit 1 - } - - if ([string]::IsNullOrWhiteSpace($env:WIN_CSC_PUBLISHER_NAME)) { - Write-Err 'Windows release signing requires WIN_CSC_PUBLISHER_NAME to match the signing certificate subject.' - exit 1 - } -} - function Load-LocalReleaseEnv([string]$RootPath) { $configured = [Environment]::GetEnvironmentVariable('KUN_RELEASE_ENV', 'Process') if (-not $configured) { @@ -97,8 +83,6 @@ function Load-LocalReleaseEnv([string]$RootPath) { $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path Set-Location $Root Load-LocalReleaseEnv $Root -Assert-WindowsSigningConfiguration -$env:KUN_REQUIRE_WINDOWS_SIGNING = '1' if ($Stable -and $Frontier) { Write-Err 'Use only one of -Stable or -Frontier.' @@ -227,15 +211,6 @@ if ($installer.Count -ne 1) { exit 1 } -Write-Info 'Verifying Windows code signatures and secure timestamps...' -try { - & (Join-Path $Root 'scripts\verify-windows-signing.ps1') ` - -FilePath $installer.FullName, (Join-Path $Root 'dist\win-unpacked\Kun.exe') -} catch { - Write-Err "Windows code-signing verification failed: $($_.Exception.Message)" - exit 1 -} - Write-Info 'Smoking GUI-bundled Kun terminal command and shared version...' & npm run smoke:packaged-cli -- --resources dist/win-unpacked/resources --expected-version $ReleaseVersion if ($LASTEXITCODE -ne 0) {