From bae7e60d30e9d0199762cd59c07d24cb94d92376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=90=91=E9=98=B3?= Date: Sun, 30 Aug 2026 00:16:37 +0800 Subject: [PATCH 1/2] fix(test): restore cross-platform quality gates --- build/installer-process-check.nsh | 1 + build/installer.nsh | 1 + build/windows-installer-migration-paths.ps1 | 6 +- kun/scripts/run-tests.mjs | 4 + kun/scripts/runtime-build-manifest.test.mjs | 5 +- .../file/file-session-store.ordering.test.ts | 7 +- .../hybrid/hybrid-thread-store-migrations.ts | 62 ++- .../adapters/hybrid/hybrid-thread-store.ts | 69 +-- .../tool/builtin-bash-session-state.ts | 19 +- kun/src/adapters/tool/builtin-shell-utils.ts | 11 +- ...iagram-visualization-tool-provider.test.ts | 2 +- .../tool/extension-tool-provider.test.ts | 2 +- .../adapters/tool/github-mcp-credential.ts | 13 +- kun/src/delegation/child-agent-executor.ts | 74 +-- .../delegation/child-agent-result-support.ts | 65 +++ kun/src/loop/agent-loop-execution.ts | 17 +- kun/src/manager/shared-data-store.test.ts | 7 +- kun/src/ppt/ppt-design-policy.test.ts | 15 + kun/src/ppt/ppt-design-policy.ts | 8 +- kun/src/ppt/pptd-to-canvas.ts | 4 +- kun/src/review/git-review-target.ts | 5 +- .../agent-sdk-runtime-limits-errors.test.ts | 2 +- kun/src/security/secret-store.test.ts | 5 +- kun/src/server/runtime-discovery.test.ts | 3 +- kun/src/server/runtime-lifecycle.test.ts | 3 +- .../services/chromium-browser-cookies.test.ts | 5 +- kun/src/services/chromium-browser-cookies.ts | 4 +- .../services/extension-agent-service-core.ts | 16 +- .../extension-agent-service-event-usage.ts | 13 + .../gateway-credential-service.test.ts | 6 +- ...nnection-registry.credential-fence.test.ts | 33 +- .../services/official-provider-cli.test.ts | 4 +- .../turn-service-steering-operations.ts | 3 + kun/src/tui/connect-common.test.ts | 37 ++ kun/src/tui/connect-common.ts | 16 +- kun/src/tui/connect-dialog-base.ts | 3 +- kun/src/tui/connect-dialog.ts | 7 +- kun/src/tui/model-dialog.ts | 5 +- .../adapter-cases/builtin-tools-1.cases.ts | 17 +- .../adapter-cases/builtin-tools-2.cases.ts | 11 +- .../adapter-cases/builtin-tools-3.cases.ts | 11 +- .../image-gen-tool-provider-1.cases.ts | 6 +- .../media-gen-tool-provider-2.cases.ts | 12 +- .../adapter-cases/model-client-6.cases.ts | 3 +- kun/tests/builtin-profiles.test.ts | 8 +- kun/tests/create-plan-tool.test.ts | 5 +- .../agent-loop-interruption-core.cases.ts | 2 +- .../agent-loop-interruption-graph.cases.ts | 7 +- .../loop-agent-compaction-core.cases.ts | 2 +- .../loop-cases/loop-agent-plans.cases.ts | 6 +- kun/tests/loop-test-harness.ts | 36 +- kun/tests/review.test.ts | 7 +- .../graph-runtime-creation-authority.cases.ts | 2 +- .../graph-runtime-creation-recovery.cases.ts | 2 +- .../graph-runtime-terminal-live.cases.ts | 2 +- ...graph-runtime-terminal-settlement.cases.ts | 2 +- kun/tests/skill-tool-provider.test.ts | 6 +- .../http-server-threads.cases.ts | 15 +- .../http-server-turns-sse.cases.ts | 4 +- .../runtime-factory-usage.cases.ts | 3 +- .../thread-service-state.cases.ts | 2 +- kun/tests/tui-cases/client-streaming.cases.ts | 1 + .../tui-cases/pi-app-attachments.cases.ts | 20 +- .../tui-cases/pi-app-provider-auth.cases.ts | 8 +- kun/tests/user-input-disabled.test.ts | 4 +- packages/provider-catalog/src/index.test.ts | 21 +- packages/provider-catalog/src/index.ts | 28 +- .../src/opencode-free-models.ts | 7 + scripts/check-windows-installer-syntax.cjs | 1 + scripts/fixtures/update-handoff-owner.cjs | 4 +- ...smoke-packaged-update-handoff-recycled.cjs | 10 +- scripts/smoke-packaged-update-handoff.cjs | 9 +- .../smoke-packaged-update-handoff.test.cjs | 9 + scripts/smoke-windows-installer-migration.ps1 | 20 + scripts/verify-linux-package-architecture.mjs | 4 - ...verify-linux-package-architecture.test.mjs | 1 - src/main/agent-sdk-installer-network.ts | 2 +- src/main/app-icon.ts | 6 +- src/main/atomic-json-file.test.ts | 4 +- src/main/bundled-skill-resources.ts | 12 +- .../claw-runtime.thread-model-routing.test.ts | 9 +- src/main/gui-updater.test.ts | 7 + src/main/index.ts | 165 +++---- .../ipc/app-ipc-schemas/settings-model.ts | 14 + ...r-app-ipc-handlers.project-runtime.test.ts | 4 + .../register-app-ipc-handlers.test-support.ts | 3 + ...gister-app-ipc-handlers.ui-runtime.test.ts | 46 +- ...egister-app-ipc-handlers.workspace.test.ts | 12 +- ...ter-extension-ipc-handlers.test-support.ts | 3 + src/main/kun-process.test.ts | 13 +- src/main/main-app-context.ts | 7 +- src/main/main-bundle-path.test.ts | 15 + src/main/main-bundle-path.ts | 8 + src/main/main-desktop-entry.ts | 83 ++++ src/main/main-entry-routing.test.ts | 15 + src/main/packaging-config.hooks.test.ts | 1 + ...time-data-dir-preserving-migration.test.ts | 8 +- src/main/runtime/kun-adapter.test.ts | 3 +- .../runtime/kun-runtime-config-service.ts | 14 +- .../runtime/kun-serve-replacement.test.ts | 18 +- src/main/settings-store.persistence.test.ts | 6 +- src/main/settings-store.test.ts | 3 +- src/main/ui-plugin-bundled.ts | 6 +- src/main/update-health-argv.ts | 27 ++ src/main/update-health-bootstrap.test.ts | 94 ++++ src/main/update-health-bootstrap.ts | 77 +++ src/main/update-health-check.test.ts | 49 ++ src/main/update-health-check.ts | 40 +- src/main/update-health-probe.test.ts | 89 +++- src/main/update-health-probe.ts | 455 ++++++++++++++++-- ...nstaller-migration.recovery-safety.test.ts | 9 +- ...taller-migration.source-resolution.test.ts | 6 +- src/main/windows-installer-migration.test.ts | 1 + ...ws-installer-migration.transaction.test.ts | 38 +- src/renderer/src/agent/kun-runtime.test.ts | 4 +- ...oatingComposer.capabilities-skills.test.ts | 2 +- .../SidebarProjectRows.task-surface.test.ts | 1 + .../components/chat/StreamdownCode.test.ts | 1 + .../chat/message-timeline-cards.test.ts | 20 +- .../components/design/DesignAIRail.test.ts | 6 +- .../design/canvas/CanvasToolbar.test.ts | 2 +- .../plan/PlanScheduledBuildDialog.test.ts | 2 +- .../components/sdd/SddAssistantPanel.test.ts | 2 +- ...s-section-agents-provider-controls.test.ts | 2 +- ...-section-agents-provider-discovery.test.ts | 8 +- ...gs-section-agents-provider-imports.test.ts | 8 +- ...ings-section-agents-provider-races.test.ts | 8 +- ...s-section-agents-provider-registry.test.ts | 4 +- ...tion-agents-provider-subscriptions.test.ts | 4 +- ...gs-section-providers-connection-panels.tsx | 9 +- ...ttings-section-providers-lifecycle.test.ts | 2 +- .../settings-section-providers.test.ts | 4 +- .../settings-section-worktree.test.ts | 10 +- .../subagents/SubagentSettingsEditor.test.ts | 4 +- .../use-provider-lifecycle-actions.test.ts | 16 +- .../use-provider-lifecycle-actions.ts | 10 +- .../workbench/UsageQuotaPanel.test.ts | 1 + .../WriteAssistantPanel.subagent.test.ts | 1 + .../write/WriteAssistantPanel.test.ts | 4 +- ...gn-turn-prompt.code-canvas-context.test.ts | 14 +- .../src/extensions/contribution-ids.test.ts | 6 +- .../src/lib/claw-model-options.test.ts | 10 +- .../src/locales/en/common/agents-graph.json | 4 + .../src/locales/en/common/commands-sdd.json | 3 +- src/renderer/src/locales/hi/settings.ts | 1 + .../hi/settings/provider-media-mcp.json | 3 + src/renderer/src/locales/ja/settings.ts | 1 + .../ja/settings/provider-media-mcp.json | 3 + src/renderer/src/locales/ko/settings.ts | 1 + .../ko/settings/provider-media-mcp.json | 3 + src/renderer/src/locales/ru/settings.ts | 1 + .../ru/settings/provider-media-mcp.json | 3 + src/renderer/src/locales/th/settings.ts | 1 + .../th/settings/provider-media-mcp.json | 3 + .../src/locales/zh/common/agents-graph.json | 4 + .../src/palette/palette-sources.test.ts | 2 +- .../chat-store-navigation-actions.test.ts | 3 +- .../chat-store-navigation-refresh.test.ts | 4 +- .../store/chat-store-runtime-errors.test.ts | 2 +- .../src/store/chat-store-runtime-sink.test.ts | 19 +- ...ore-thread-actions-guidance-switch.test.ts | 7 + .../store/chat-store-thread-refresh.test.ts | 5 +- .../app-settings-provider.accounts.test.ts | 1 - .../app-settings-provider.presets.test.ts | 6 +- src/shared/kun-gui-api-protected-approval.ts | 12 + src/shared/kun-gui-api-surface.ts | 12 +- .../model-provider-preset-catalog-core.ts | 33 +- src/shared/model-provider-preset-types.ts | 40 +- src/shared/model-provider-presets.test.ts | 2 - 169 files changed, 1946 insertions(+), 650 deletions(-) create mode 100644 kun/src/delegation/child-agent-result-support.ts create mode 100644 kun/src/tui/connect-common.test.ts create mode 100644 packages/provider-catalog/src/opencode-free-models.ts create mode 100644 src/main/main-bundle-path.test.ts create mode 100644 src/main/main-bundle-path.ts create mode 100644 src/main/main-desktop-entry.ts create mode 100644 src/main/main-entry-routing.test.ts create mode 100644 src/main/update-health-argv.ts create mode 100644 src/main/update-health-bootstrap.test.ts create mode 100644 src/main/update-health-bootstrap.ts create mode 100644 src/shared/kun-gui-api-protected-approval.ts diff --git a/build/installer-process-check.nsh b/build/installer-process-check.nsh index 3f95a1feb..c67f2c351 100644 --- a/build/installer-process-check.nsh +++ b/build/installer-process-check.nsh @@ -11,6 +11,7 @@ File /oname=$PLUGINSDIR\windows-installer-migration-journal.ps1 "${PROJECT_DIR}\build\windows-installer-migration-journal.ps1" File /oname=$PLUGINSDIR\windows-installer-migration-filesystem.ps1 "${PROJECT_DIR}\build\windows-installer-migration-filesystem.ps1" File /oname=$PLUGINSDIR\windows-installer-migration-actions.ps1 "${PROJECT_DIR}\build\windows-installer-migration-actions.ps1" + File /oname=$PLUGINSDIR\windows-installer-migration-recovery-env.ps1 "${PROJECT_DIR}\build\windows-installer-migration-recovery-env.ps1" File /oname=$PLUGINSDIR\windows-installer-migration-transaction.ps1 "${PROJECT_DIR}\build\windows-installer-migration-transaction.ps1" StrCpy $KunInstallerHelperPath "$PLUGINSDIR\kun-windows-installer-migration.ps1" System::Call 'kernel32::GetCurrentProcessId() i .r0' diff --git a/build/installer.nsh b/build/installer.nsh index 20be8aa41..37fbe1ab3 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -86,6 +86,7 @@ Var /GLOBAL KunInstallerStopDiagnosticPath File /oname=$PLUGINSDIR\windows-installer-migration-journal.ps1 "${PROJECT_DIR}\build\windows-installer-migration-journal.ps1" File /oname=$PLUGINSDIR\windows-installer-migration-filesystem.ps1 "${PROJECT_DIR}\build\windows-installer-migration-filesystem.ps1" File /oname=$PLUGINSDIR\windows-installer-migration-actions.ps1 "${PROJECT_DIR}\build\windows-installer-migration-actions.ps1" + File /oname=$PLUGINSDIR\windows-installer-migration-recovery-env.ps1 "${PROJECT_DIR}\build\windows-installer-migration-recovery-env.ps1" File /oname=$PLUGINSDIR\windows-installer-migration-transaction.ps1 "${PROJECT_DIR}\build\windows-installer-migration-transaction.ps1" StrCpy $KunInstallerHelperPath "$PLUGINSDIR\kun-windows-installer-migration.ps1" StrCpy $KunInstallerResultPath "$PLUGINSDIR\kun-windows-installer-result.txt" diff --git a/build/windows-installer-migration-paths.ps1 b/build/windows-installer-migration-paths.ps1 index 5503a7096..767ee4508 100644 --- a/build/windows-installer-migration-paths.ps1 +++ b/build/windows-installer-migration-paths.ps1 @@ -1,5 +1,9 @@ function Get-EnvironmentValue([string]$Name) { - return [Environment]::GetEnvironmentVariable($Name, 'Process') + $value = [Environment]::GetEnvironmentVariable($Name, 'Process') + if ($null -eq $value) { + return '' + } + return [string]$value } function Get-CanonicalLeaf { diff --git a/kun/scripts/run-tests.mjs b/kun/scripts/run-tests.mjs index 7e5d99b05..065583fef 100644 --- a/kun/scripts/run-tests.mjs +++ b/kun/scripts/run-tests.mjs @@ -30,8 +30,12 @@ function resolveElectronExecutable() { const vitest = join(dirname(fileURLToPath(import.meta.url)), '..', 'node_modules', 'vitest', 'vitest.mjs') const doctorFiles = [ + 'src/adapters/hybrid/hybrid-thread-store.test.ts', + 'src/adapters/hybrid/hybrid-usage-backfill-sqlite.test.ts', + 'tests/hybrid-store.test.ts', 'tests/thread-store-doctor.test.ts', 'tests/thread-store-doctor-race.test.ts', + 'src/manager/usage-query-runner.test.ts', 'src/services/opencode-go-local-quota.test.ts' ] diff --git a/kun/scripts/runtime-build-manifest.test.mjs b/kun/scripts/runtime-build-manifest.test.mjs index 7c919b820..29dc5ca70 100644 --- a/kun/scripts/runtime-build-manifest.test.mjs +++ b/kun/scripts/runtime-build-manifest.test.mjs @@ -11,6 +11,9 @@ import { test('runtime build identity is stable for identical output and changes with emitted JavaScript', async () => { const root = await mkdtemp(join(tmpdir(), 'kun-runtime-build-manifest-')) try { + const packageVersion = JSON.parse( + await readFile(new URL('../package.json', import.meta.url), 'utf8') + ).version await writeFile(join(root, 'a.js'), 'export const a = 1\n', 'utf8') await writeFile(join(root, 'b.js'), 'export const b = 2\n', 'utf8') const first = await computeRuntimeBuildId(root) @@ -31,7 +34,7 @@ test('runtime build identity is stable for identical output and changes with emi assert.equal(manifest.buildId, await computeRuntimeBuildId(root)) assert.equal( manifest.serviceVersion, - process.env.KUN_APP_VERSION || process.env.KUN_RELEASE_VERSION || '0.3.8' + process.env.KUN_APP_VERSION || process.env.KUN_RELEASE_VERSION || packageVersion ) assert.equal( manifest.channel, diff --git a/kun/src/adapters/file/file-session-store.ordering.test.ts b/kun/src/adapters/file/file-session-store.ordering.test.ts index eee455b94..8ab165d0d 100644 --- a/kun/src/adapters/file/file-session-store.ordering.test.ts +++ b/kun/src/adapters/file/file-session-store.ordering.test.ts @@ -14,7 +14,12 @@ import { FileSessionStore, readLatestItemsFromJsonl } from './file-session-store const roots: string[] = [] afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) + await Promise.all(roots.splice(0).map((root) => rm(root, { + recursive: true, + force: true, + maxRetries: process.platform === 'win32' ? 5 : 0, + retryDelay: 50 + }))) }) describe('FileSessionStore item ordering', () => { diff --git a/kun/src/adapters/hybrid/hybrid-thread-store-migrations.ts b/kun/src/adapters/hybrid/hybrid-thread-store-migrations.ts index 9a5767b51..54eb8b5da 100644 --- a/kun/src/adapters/hybrid/hybrid-thread-store-migrations.ts +++ b/kun/src/adapters/hybrid/hybrid-thread-store-migrations.ts @@ -1,7 +1,67 @@ import type { Database as BetterSqliteDatabase } from 'better-sqlite3' import { addColumnIfMissing } from './hybrid-thread-support.js' -export function migrateHybridThreadSchema(db: BetterSqliteDatabase): void { +export function migrateHybridThreadStore(db: BetterSqliteDatabase): void { + db.exec(` + CREATE TABLE IF NOT EXISTS threads ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + workspace TEXT NOT NULL, + model TEXT NOT NULL, + agent_surface TEXT, + mode TEXT NOT NULL, + status TEXT NOT NULL, + approval_policy TEXT NOT NULL, + sandbox_mode TEXT NOT NULL, + approval_reviewer TEXT NOT NULL DEFAULT 'user', + model_request_capture_enabled INTEGER NOT NULL DEFAULT 0, + cost_budget_usd REAL, + cost_budget_warning_sent INTEGER, + relation TEXT NOT NULL, + parent_thread_id TEXT, + forked_from_thread_id TEXT, + forked_from_title TEXT, + forked_at TEXT, + forked_from_message_count INTEGER, + forked_from_turn_count INTEGER, + goal_json TEXT, + todos_json TEXT, + extension_metadata_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + preview TEXT, + message_count INTEGER NOT NULL DEFAULT 0, + event_seq_high_water INTEGER NOT NULL DEFAULT 0, + usage_backfilled INTEGER NOT NULL DEFAULT 0, + usage_backfill_high_water INTEGER NOT NULL DEFAULT 0, + metadata_path TEXT NOT NULL, + messages_path TEXT NOT NULL, + events_path TEXT NOT NULL, + search_text TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS threads_updated_idx + ON threads(updated_at_ms DESC, id DESC); + CREATE INDEX IF NOT EXISTS threads_workspace_updated_idx + ON threads(workspace, updated_at_ms DESC, id DESC); + CREATE INDEX IF NOT EXISTS threads_status_updated_idx + ON threads(status, updated_at_ms DESC, id DESC); + CREATE INDEX IF NOT EXISTS threads_relation_updated_idx + ON threads(relation, updated_at_ms DESC, id DESC); + CREATE TABLE IF NOT EXISTS usage_events ( + thread_id TEXT NOT NULL, + seq INTEGER NOT NULL, + timestamp TEXT NOT NULL, + turn_id TEXT, model TEXT, provider_id TEXT, + usage_json TEXT NOT NULL, + PRIMARY KEY(thread_id, seq) + ); + CREATE INDEX IF NOT EXISTS usage_events_thread_seq_idx + ON usage_events(thread_id, seq); + CREATE INDEX IF NOT EXISTS usage_events_timestamp_idx + ON usage_events(timestamp); + `) addColumnIfMissing(db, 'threads', 'todos_json TEXT') addColumnIfMissing(db, 'threads', 'extension_metadata_json TEXT') addColumnIfMissing(db, 'threads', 'model_request_capture_enabled INTEGER NOT NULL DEFAULT 0') diff --git a/kun/src/adapters/hybrid/hybrid-thread-store.ts b/kun/src/adapters/hybrid/hybrid-thread-store.ts index fca474a66..237407fb6 100644 --- a/kun/src/adapters/hybrid/hybrid-thread-store.ts +++ b/kun/src/adapters/hybrid/hybrid-thread-store.ts @@ -40,7 +40,7 @@ import { UsageQueryExecutor } from '../../manager/usage-query-executor.js' import { UsageIndexUnavailableError } from '../../manager/usage-errors.js' import { JsonlFileAccessCoordinator } from '../file/jsonl-file-access.js' import { renameFileWithRetry } from '../file/atomic-write.js' -import { migrateHybridThreadSchema } from './hybrid-thread-store-migrations.js' +import { migrateHybridThreadStore } from './hybrid-thread-store-migrations.js' export { describeSqliteAbiMismatch } from './hybrid-thread-support.js' @@ -373,7 +373,7 @@ export class HybridThreadStore implements ThreadStore { this.db.pragma('journal_mode = WAL') this.db.pragma('busy_timeout = 5000') this.db.pragma('foreign_keys = ON') - this.migrate() + migrateHybridThreadStore(this.db) this.index = new HybridThreadIndexRepository(this.db, (threadId) => ({ metadataPath: this.metadataPath(threadId), messagesPath: this.messagesPath(threadId), eventsPath: this.eventsPath(threadId) @@ -417,71 +417,6 @@ export class HybridThreadStore implements ThreadStore { } } - private migrate(): void { - if (!this.db) return - this.db.exec(` - CREATE TABLE IF NOT EXISTS threads ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - workspace TEXT NOT NULL, - model TEXT NOT NULL, - agent_surface TEXT, - mode TEXT NOT NULL, - status TEXT NOT NULL, - approval_policy TEXT NOT NULL, - sandbox_mode TEXT NOT NULL, - approval_reviewer TEXT NOT NULL DEFAULT 'user', - model_request_capture_enabled INTEGER NOT NULL DEFAULT 0, - cost_budget_usd REAL, - cost_budget_warning_sent INTEGER, - relation TEXT NOT NULL, - parent_thread_id TEXT, - forked_from_thread_id TEXT, - forked_from_title TEXT, - forked_at TEXT, - forked_from_message_count INTEGER, - forked_from_turn_count INTEGER, - goal_json TEXT, - todos_json TEXT, - extension_metadata_json TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - created_at_ms INTEGER NOT NULL, - updated_at_ms INTEGER NOT NULL, - preview TEXT, - message_count INTEGER NOT NULL DEFAULT 0, - event_seq_high_water INTEGER NOT NULL DEFAULT 0, - usage_backfilled INTEGER NOT NULL DEFAULT 0, - usage_backfill_high_water INTEGER NOT NULL DEFAULT 0, - metadata_path TEXT NOT NULL, - messages_path TEXT NOT NULL, - events_path TEXT NOT NULL, - search_text TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS threads_updated_idx - ON threads(updated_at_ms DESC, id DESC); - CREATE INDEX IF NOT EXISTS threads_workspace_updated_idx - ON threads(workspace, updated_at_ms DESC, id DESC); - CREATE INDEX IF NOT EXISTS threads_status_updated_idx - ON threads(status, updated_at_ms DESC, id DESC); - CREATE INDEX IF NOT EXISTS threads_relation_updated_idx - ON threads(relation, updated_at_ms DESC, id DESC); - CREATE TABLE IF NOT EXISTS usage_events ( - thread_id TEXT NOT NULL, - seq INTEGER NOT NULL, - timestamp TEXT NOT NULL, - turn_id TEXT, model TEXT, provider_id TEXT, - usage_json TEXT NOT NULL, - PRIMARY KEY(thread_id, seq) - ); - CREATE INDEX IF NOT EXISTS usage_events_thread_seq_idx - ON usage_events(thread_id, seq); - CREATE INDEX IF NOT EXISTS usage_events_timestamp_idx - ON usage_events(timestamp); - `) - migrateHybridThreadSchema(this.db) - } - private cachedStatement(sql: string): Statement { if (!this.db) throw new Error('sqlite unavailable') let statement = this.statementCache.get(sql) diff --git a/kun/src/adapters/tool/builtin-bash-session-state.ts b/kun/src/adapters/tool/builtin-bash-session-state.ts index 68b1fca22..c78bbcb02 100644 --- a/kun/src/adapters/tool/builtin-bash-session-state.ts +++ b/kun/src/adapters/tool/builtin-bash-session-state.ts @@ -17,9 +17,9 @@ export const DEFAULT_MAX_RUNNING_BACKGROUND_BASH_SESSIONS = 32 export const DEFAULT_MAX_RUNNING_BACKGROUND_BASH_SESSIONS_PER_THREAD = 4 export const DEFAULT_MAX_BACKGROUND_BASH_TIMEOUT_SECONDS = DEFAULT_BACKGROUND_BASH_TIMEOUT_SECONDS -export function terminateBashProcessTree(child: ChildProcess): Promise { - terminateSpawnTree(child) - return new Promise((resolve, reject) => { +export async function terminateBashProcessTree(child: ChildProcess): Promise { + const treeTerminator = terminateSpawnTree(child) + await new Promise((resolve, reject) => { const startedAt = Date.now() let forced = false const waitOrForce = () => { @@ -40,6 +40,19 @@ export function terminateBashProcessTree(child: ChildProcess): Promise { } setTimeout(waitOrForce, 25) }) + if (treeTerminator && treeTerminator.exitCode === null && treeTerminator.signalCode === null) { + await new Promise((resolve) => { + const timer = setTimeout(resolve, STOP_GRACE_MS) + treeTerminator.once('close', () => { + clearTimeout(timer) + resolve() + }) + treeTerminator.once('error', () => { + clearTimeout(timer) + resolve() + }) + }) + } } export function bashProcessTreeIsAlive(child: ChildProcess): boolean { diff --git a/kun/src/adapters/tool/builtin-shell-utils.ts b/kun/src/adapters/tool/builtin-shell-utils.ts index 4730f723a..041e97fa1 100644 --- a/kun/src/adapters/tool/builtin-shell-utils.ts +++ b/kun/src/adapters/tool/builtin-shell-utils.ts @@ -447,12 +447,12 @@ export function terminateSpawnTree( signal?: NodeJS.Signals spawnImpl?: SpawnLike } = {} -): void { +): ChildProcess | undefined { const signal = options.signal ?? 'SIGTERM' const pid = child.pid if (!pid) { child.kill(signal) - return + return undefined } if ((options.platform ?? process.platform) === 'win32') { @@ -465,18 +465,19 @@ export function terminateSpawnTree( child.kill(signal) }) taskkill.unref?.() - return + return taskkill } catch { child.kill(signal) - return + return undefined } } try { process.kill(-pid, signal) - return + return undefined } catch { child.kill(signal) + return undefined } } diff --git a/kun/src/adapters/tool/diagram-visualization-tool-provider.test.ts b/kun/src/adapters/tool/diagram-visualization-tool-provider.test.ts index 14631139d..959e57119 100644 --- a/kun/src/adapters/tool/diagram-visualization-tool-provider.test.ts +++ b/kun/src/adapters/tool/diagram-visualization-tool-provider.test.ts @@ -81,7 +81,7 @@ describe('show_diagram provider', () => { launcher: 'diagram_design', profile: 'diagram-designer', agentSurface: 'design', sandboxMode: 'workspace-write', security: { memoryEnabled: false } }) - expect(calls[0]?.workspace).toEqual(expect.stringContaining('/.kun-design/diagram-prototypes/')) + expect(calls[0]?.workspace).toEqual(expect.stringContaining(join('.kun-design', 'diagram-prototypes'))) expect(result).toMatchObject({ output: { status: 'completed', diagramPrototype: { producer: 'diagram-designer', profile: 'diagram-designer', childId: 'child-diagram', summary: 'Generated the release diagram.' diff --git a/kun/src/adapters/tool/extension-tool-provider.test.ts b/kun/src/adapters/tool/extension-tool-provider.test.ts index 04eca2b64..3f3c5ca4b 100644 --- a/kun/src/adapters/tool/extension-tool-provider.test.ts +++ b/kun/src/adapters/tool/extension-tool-provider.test.ts @@ -410,7 +410,7 @@ describe('ExtensionToolRegistry', () => { ) firstB.dispose() - expect(() => firstHost.listTools(context({ extensionToolCatalogEpoch: firstEpoch }))).toThrow( + await expect(firstHost.listTools(context({ extensionToolCatalogEpoch: firstEpoch }))).rejects.toThrow( ExtensionToolCatalogDriftError ) }) diff --git a/kun/src/adapters/tool/github-mcp-credential.ts b/kun/src/adapters/tool/github-mcp-credential.ts index 485cb8432..b670f3cae 100644 --- a/kun/src/adapters/tool/github-mcp-credential.ts +++ b/kun/src/adapters/tool/github-mcp-credential.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto' import { execFile } from 'node:child_process' import { constants, accessSync, realpathSync, statSync } from 'node:fs' -import { delimiter, posix, win32 } from 'node:path' +import { posix, win32 } from 'node:path' import type { McpServerConfig } from '../../contracts/capabilities.js' import { KUN_GITHUB_PAT_ENV_VAR, @@ -207,11 +207,16 @@ function githubCliExecutableCandidates(platform: NodeJS.Platform, env: NodeJS.Pr } function githubCliPathCandidates(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string[] { - if (!env.PATH) return [] + const pathValue = env.PATH + if (!pathValue) return [] const pathApi = platform === 'win32' ? win32 : posix const executable = platform === 'win32' ? 'gh.exe' : 'gh' - return [...new Set(env.PATH.split(platform === 'win32' ? win32.delimiter : delimiter).map((item) => item.trim()).filter(Boolean))] - .map((directory) => pathApi.join(directory, executable)) + const pathDelimiter = platform === 'win32' ? win32.delimiter : posix.delimiter + const directories = pathValue + .split(pathDelimiter) + .map((entry) => entry.trim()) + .filter(Boolean) + return [...new Set(directories)].map((directory) => pathApi.join(directory, executable)) } function windowsGitHubCliFallbackCandidates(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string[] { diff --git a/kun/src/delegation/child-agent-executor.ts b/kun/src/delegation/child-agent-executor.ts index b4dc06a49..567991e4e 100644 --- a/kun/src/delegation/child-agent-executor.ts +++ b/kun/src/delegation/child-agent-executor.ts @@ -5,7 +5,6 @@ import { InMemoryThreadStore } from '../adapters/in-memory-thread-store.js' import { InMemoryUserInputGate } from '../adapters/in-memory-user-input-gate.js' import { setSystemPrompt, type ImmutablePrefix } from '../cache/immutable-prefix.js' import { SUBAGENT_READ_ONLY_TOOL_NAMES, type ModelCapabilityMetadata } from '../contracts/capabilities.js' -import type { TurnItem } from '../contracts/items.js' import { ChildRunFailureSchema, type ChildRunFailure } from '../contracts/subagent-retry.js' import { DEFAULT_APPROVAL_REVIEWER, @@ -60,6 +59,11 @@ import { buildFastContextEvidencePack } from './fast-context-evidence.js' import { createFastContextToolHost } from './fast-context-tool-host.js' import { resolveChildEpisodeLimits } from './child-episode-limits.js' import { withGlobalSubagentTools } from './subagent-global-tool-policy.js' +import { + childResultUsedNoTextSummary, + childToolEvidence, + FAST_CONTEXT_RECOVERABLE_LOOP_ERROR_CODES +} from './child-agent-result-support.js' export type ChildDelegatedRuntimeFactory = (input: { threads: ThreadService @@ -599,66 +603,6 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch } } -/** Loop bookkeeping error codes that a completed Fast Context child may - * outrank with its evidence pack (kun/src/loop/round-outcome-recovery-phase.ts). - * Anything outside this set remains fatal and fails the run as before. */ -const FAST_CONTEXT_RECOVERABLE_LOOP_ERROR_CODES = new Set([ - 'model_empty_response', - 'empty_post_tool_continuation', - 'tool_loop_suppressed' -]) - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -/** True when childResultSource had no assistant text and fell back to a - * tool_result stringification or loop error text — the fake-summary cases. */ -function childResultUsedNoTextSummary(items: readonly TurnItem[], turnId: string): boolean { - const turnItems = items.filter((item) => item.turnId === turnId) - const hasAssistantText = turnItems.some( - (item) => item.kind === 'assistant_text' && item.text.trim().length > 0 - ) - if (hasAssistantText) return false - return turnItems.some( - (item) => item.kind === 'tool_result' || item.kind === 'error' - ) -} - -function childToolEvidence(items: readonly TurnItem[], turnId: string): string[] { - const results = new Map(items - .filter((item): item is Extract => - item.turnId === turnId && item.kind === 'tool_result') - .map((item) => [item.callId, item])) - return items - .filter((item): item is Extract => - item.turnId === turnId && item.kind === 'tool_call') - .filter((item) => { - const result = results.get(item.callId) - return Boolean(result && !result.isError && result.status === 'completed') - }) - .slice(0, 32) - .map((item) => { - const result = results.get(item.callId)! - const target = toolEvidenceTarget(item.arguments) - const digest = evidenceDigest(result.output) - return `${item.toolName}${target ? ` ${target}` : ''}: completed${digest ? ` — ${digest}` : ''}` - }) -} - -function evidenceDigest(output: unknown): string { - const serialized = typeof output === 'string' ? output : safeJson(output) - return serialized.replace(/\s+/g, ' ').trim().slice(0, 500) -} - -function safeJson(value: unknown): string { - try { - return JSON.stringify(value) - } catch { - return String(value) - } -} - function intersectDefinedLists(...lists: Array): string[] | undefined { const defined = lists.filter((list): list is readonly string[] => Boolean(list)) if (!defined.length) return undefined @@ -674,14 +618,6 @@ function unique(values: readonly string[]): string[] { return [...new Set(values)] } -function toolEvidenceTarget(args: Record): string { - for (const key of ['path', 'filePath', 'file_path', 'query', 'command']) { - const value = args[key] - if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 300) - } - return '' -} - function childThreadTitle(childId: string, label?: string, profile?: string): string { const suffix = label?.trim() || profile?.trim() || childId return `Child agent: ${suffix}` diff --git a/kun/src/delegation/child-agent-result-support.ts b/kun/src/delegation/child-agent-result-support.ts new file mode 100644 index 000000000..c74c7ee67 --- /dev/null +++ b/kun/src/delegation/child-agent-result-support.ts @@ -0,0 +1,65 @@ +import type { TurnItem } from '../contracts/items.js' + +/** Loop bookkeeping errors a completed Fast Context result can outrank. */ +export const FAST_CONTEXT_RECOVERABLE_LOOP_ERROR_CODES = new Set([ + 'model_empty_response', + 'empty_post_tool_continuation', + 'tool_loop_suppressed' +]) + +/** Whether result materialization had to fall back to tool/error text. */ +export function childResultUsedNoTextSummary( + items: readonly TurnItem[], + turnId: string +): boolean { + const turnItems = items.filter((item) => item.turnId === turnId) + const hasAssistantText = turnItems.some( + (item) => item.kind === 'assistant_text' && item.text.trim().length > 0 + ) + if (hasAssistantText) return false + return turnItems.some( + (item) => item.kind === 'tool_result' || item.kind === 'error' + ) +} + +export function childToolEvidence(items: readonly TurnItem[], turnId: string): string[] { + const results = new Map(items + .filter((item): item is Extract => + item.turnId === turnId && item.kind === 'tool_result') + .map((item) => [item.callId, item])) + return items + .filter((item): item is Extract => + item.turnId === turnId && item.kind === 'tool_call') + .filter((item) => { + const result = results.get(item.callId) + return Boolean(result && !result.isError && result.status === 'completed') + }) + .slice(0, 32) + .map((item) => { + const result = results.get(item.callId)! + const target = toolEvidenceTarget(item.arguments) + const digest = evidenceDigest(result.output) + return `${item.toolName}${target ? ` ${target}` : ''}: completed${digest ? ` — ${digest}` : ''}` + }) +} + +function evidenceDigest(output: unknown): string { + const serialized = typeof output === 'string' ? output : safeJson(output) + return serialized.replace(/\s+/g, ' ').trim().slice(0, 500) +} + +function safeJson(value: unknown): string { + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +function toolEvidenceTarget(args: Record): string { + for (const key of ['path', 'filePath', 'file_path', 'query', 'command']) { + const value = args[key] + if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 300) + } + return '' +} diff --git a/kun/src/loop/agent-loop-execution.ts b/kun/src/loop/agent-loop-execution.ts index 0be47742b..e9da074cf 100644 --- a/kun/src/loop/agent-loop-execution.ts +++ b/kun/src/loop/agent-loop-execution.ts @@ -2,12 +2,13 @@ import type { ModelRoundOutcome, TurnRunOutcome } from './turn-execution-types.j import { makeErrorItem } from '../domain/item.js' import { normalizeTurnLimits } from './turn-limits.js' import { AgentLoopTurnLifecycle } from './agent-loop-turn-lifecycle.js' +import { STREAM_DISCONNECTED_CODE } from './stream-disconnection-failure.js' const RECOVERABLE_GRAPH_LEAD_MODEL_FAILURE_CODES = new Set([ - 'stream_disconnected', 'stream_idle_timeout', 'stream_read_error', - 'stream_truncated' + 'stream_truncated', + STREAM_DISCONNECTED_CODE ]) // A GraphRun may live for hours, but one process-local Lead wake-up must not. @@ -229,14 +230,6 @@ export class AgentLoopExecution extends AgentLoopTurnLifecycle { ) { const activeTurn = await this.opts.turns.getTurn(threadId, turnId) if (activeTurn?.status === 'running' && activeTurn.orchestration === 'graph') { - const rawCode = typeof failure.details === 'object' && failure.details !== null && - typeof (failure.details as { rawCode?: unknown }).rawCode === 'string' - ? (failure.details as { rawCode: string }).rawCode - : failure.code - const rawMessage = typeof failure.details === 'object' && failure.details !== null && - typeof (failure.details as { rawMessage?: unknown }).rawMessage === 'string' - ? (failure.details as { rawMessage: string }).rawMessage - : failure.error // The stream error event is durable, but thread rehydration is // item-based. Persist before releasing the execution lease so a // concurrent Graph wake-up cannot reuse this runner while it is @@ -247,8 +240,8 @@ export class AgentLoopExecution extends AgentLoopTurnLifecycle { id: `item_${turnId}_error`, turnId, threadId, - message: rawMessage, - code: rawCode, + message: failure.error, + code: failure.code, ...(failure.details !== undefined ? { details: failure.details } : {}), ...(failure.modelRequestFailure ? { modelRequestFailure: failure.modelRequestFailure } diff --git a/kun/src/manager/shared-data-store.test.ts b/kun/src/manager/shared-data-store.test.ts index 53af138cc..854ee51ab 100644 --- a/kun/src/manager/shared-data-store.test.ts +++ b/kun/src/manager/shared-data-store.test.ts @@ -20,7 +20,12 @@ import { requiresAtomicReplace } from './shared-data-store-core.js' const roots: string[] = [] afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) + await Promise.all(roots.splice(0).map((root) => rm(root, { + recursive: true, + force: true, + maxRetries: process.platform === 'win32' ? 5 : 0, + retryDelay: 50 + }))) }) async function dataStore(): Promise { diff --git a/kun/src/ppt/ppt-design-policy.test.ts b/kun/src/ppt/ppt-design-policy.test.ts index 201e948c0..5b93c3bad 100644 --- a/kun/src/ppt/ppt-design-policy.test.ts +++ b/kun/src/ppt/ppt-design-policy.test.ts @@ -56,6 +56,21 @@ describe('PPT core design policy binding', () => { await writeFile(rulesPath, `${JSON.stringify(rules, null, 2)}\n`) await expect(loadPptCoreDesignPolicy(root)).rejects.toThrow() }) + + it('keeps policy identity stable across LF and CRLF checkouts', async () => { + const expected = await loadPptCoreDesignPolicy(toolchain) + const root = await copiedToolchain() + await Promise.all([PPT_CORE_DESIGN_POLICY_PATH, PPT_CORE_DESIGN_POLICY_RULES_PATH].map(async (path) => { + const filePath = join(root, 'reference', path) + const content = await readFile(filePath, 'utf8') + await writeFile(filePath, content.replace(/\r?\n/g, '\r\n')) + })) + + const actual = await loadPptCoreDesignPolicy(root) + expect(actual.sha256).toBe(expected.sha256) + expect(actual.markdownSha256).toBe(expected.markdownSha256) + expect(actual.rulesSha256).toBe(expected.rulesSha256) + }) }) async function copiedToolchain(): Promise { diff --git a/kun/src/ppt/ppt-design-policy.ts b/kun/src/ppt/ppt-design-policy.ts index 25f61a1ba..4b0f8baf9 100644 --- a/kun/src/ppt/ppt-design-policy.ts +++ b/kun/src/ppt/ppt-design-policy.ts @@ -103,10 +103,12 @@ export type PptCoreDesignPolicy = { /** Load and verify the canonical policy bundled with the PPT toolchain. */ export async function loadPptCoreDesignPolicy(toolchainDirectory: string): Promise { const referenceDirectory = resolve(toolchainDirectory, 'reference') - const [content, rulesContent] = await Promise.all([ + const [rawContent, rawRulesContent] = await Promise.all([ readFile(resolve(referenceDirectory, PPT_CORE_DESIGN_POLICY_PATH), 'utf8'), readFile(resolve(referenceDirectory, PPT_CORE_DESIGN_POLICY_RULES_PATH), 'utf8') ]) + const content = canonicalText(rawContent) + const rulesContent = canonicalText(rawRulesContent) const declaredVersion = /^Policy-Version:\s*(\S+)\s*$/m.exec(content)?.[1] if (declaredVersion !== PPT_CORE_DESIGN_POLICY_VERSION) { throw new Error( @@ -162,6 +164,10 @@ function sha256(content: string): string { return createHash('sha256').update(content).digest('hex') } +function canonicalText(content: string): string { + return content.replace(/\r\n?/g, '\n') +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } diff --git a/kun/src/ppt/pptd-to-canvas.ts b/kun/src/ppt/pptd-to-canvas.ts index a5852b085..1adb8e4c1 100644 --- a/kun/src/ppt/pptd-to-canvas.ts +++ b/kun/src/ppt/pptd-to-canvas.ts @@ -421,9 +421,9 @@ function resolveImageUrl( const absolute = resolve(projectDir, src) if (workspaceRoot) { const rel = relative(workspaceRoot, absolute) - return rel.startsWith('..') ? absolute : rel + return rel.startsWith('..') ? absolute : rel.replaceAll('\\', '/') } - return relative(projectDir, absolute) + return relative(projectDir, absolute).replaceAll('\\', '/') } function mapTable( diff --git a/kun/src/review/git-review-target.ts b/kun/src/review/git-review-target.ts index 7dfec3c3c..2dee2ddbc 100644 --- a/kun/src/review/git-review-target.ts +++ b/kun/src/review/git-review-target.ts @@ -7,7 +7,10 @@ import type { ReviewTarget } from '../contracts/review.js' const execFileAsync = promisify(execFile) const DEFAULT_DIFF_MAX_BYTES = 256 * 1024 -const GIT_COMMAND_TIMEOUT_MS = 10_000 +// Git for Windows can spend several seconds starting under heavy process and +// antivirus load. Keep the command bounded without rejecting healthy repos +// at the Unix-oriented 10-second budget. +const GIT_COMMAND_TIMEOUT_MS = process.platform === 'win32' ? 30_000 : 10_000 const GIT_COMMAND_MAX_BUFFER = 384 * 1024 const REPOSITORY_SEARCH_MAX_DEPTH = 4 const REPOSITORY_SEARCH_MAX_DIRECTORIES = 2_000 diff --git a/kun/src/runtime/agent-sdk/agent-sdk-runtime-limits-errors.test.ts b/kun/src/runtime/agent-sdk/agent-sdk-runtime-limits-errors.test.ts index f22581298..6dbe5484f 100644 --- a/kun/src/runtime/agent-sdk/agent-sdk-runtime-limits-errors.test.ts +++ b/kun/src/runtime/agent-sdk/agent-sdk-runtime-limits-errors.test.ts @@ -505,7 +505,7 @@ describe('AgentSdkRuntime.runTurn', () => { })) expect(finished.at(-1)?.error).toBe('turn exceeded 3 model steps') expect(debugSink.snapshot()[0]?.exchanges[0]).toMatchObject({ - status: 'completed', + status: 'failed', decoded: { error: 'error_max_turns', stopReason: 'error' diff --git a/kun/src/security/secret-store.test.ts b/kun/src/security/secret-store.test.ts index 9f9a92174..77fa399e4 100644 --- a/kun/src/security/secret-store.test.ts +++ b/kun/src/security/secret-store.test.ts @@ -48,7 +48,10 @@ function installFakeAtomicJsonManager(dataDir: string) { return Response.json({ snapshot: structuredClone(current) }) } if (body.expectedRevision !== current.revision) { - return Response.json({ currentRevision: current.revision }, { status: 409 }) + return Response.json({ + code: 'revision_conflict', + currentRevision: current.revision + }, { status: 409 }) } const next = { revision: current.revision + 1, diff --git a/kun/src/server/runtime-discovery.test.ts b/kun/src/server/runtime-discovery.test.ts index 473d550e8..f6097d9f3 100644 --- a/kun/src/server/runtime-discovery.test.ts +++ b/kun/src/server/runtime-discovery.test.ts @@ -12,6 +12,7 @@ import { runtimeDiscoveryPath, withRuntimeStartLock } from './runtime-discovery.js' +import { KUN_VERSION } from '../version.js' const roots: string[] = [] @@ -51,7 +52,7 @@ describe('runtime discovery', () => { baseUrl: 'http://127.0.0.1:18899', runtimeToken: 'secret-token', insecure: false, - serviceVersion: '0.1.0', + serviceVersion: KUN_VERSION, buildId, launchMode: 'foreground' }) diff --git a/kun/src/server/runtime-lifecycle.test.ts b/kun/src/server/runtime-lifecycle.test.ts index e4035e824..a9145b254 100644 --- a/kun/src/server/runtime-lifecycle.test.ts +++ b/kun/src/server/runtime-lifecycle.test.ts @@ -8,6 +8,7 @@ import { runtimeDataDirOwnerPath } from './runtime-data-dir-migration-lock.js' import { runtimeDiscoveryPath } from './runtime-discovery.js' +import { KUN_VERSION } from '../version.js' const roots: string[] = [] const servers: KunServeHandle[] = [] @@ -83,7 +84,7 @@ describe('runtime lifecycle API', () => { const info = await infoResponse.json() expect(info).toMatchObject({ instanceId: server.instanceId, - serviceVersion: '0.1.0', + serviceVersion: KUN_VERSION, buildId: 'b'.repeat(64), launchMode: 'shared' }) diff --git a/kun/src/services/chromium-browser-cookies.test.ts b/kun/src/services/chromium-browser-cookies.test.ts index 49656c05e..9e77f7e02 100644 --- a/kun/src/services/chromium-browser-cookies.test.ts +++ b/kun/src/services/chromium-browser-cookies.test.ts @@ -50,8 +50,9 @@ describe('chromium-browser-cookies', () => { }] }).map((candidate) => candidate.databasePath) - expect(paths).toEqual([join(defaultProfile, 'Cookies')]) - expect(paths.some((path) => path.includes(`${join('Network', 'Cookies')}`))).toBe(false) + const normalizedPaths = paths.map((path) => path.replaceAll('\\', '/')) + expect(normalizedPaths).toEqual([join(defaultProfile, 'Cookies').replaceAll('\\', '/')]) + expect(normalizedPaths.some((path) => path.includes('/Network/Cookies'))).toBe(false) }) it('decrypts v10 cookies with DB version >= 24 domain hash prefix', () => { diff --git a/kun/src/services/chromium-browser-cookies.ts b/kun/src/services/chromium-browser-cookies.ts index 9ab30c4ac..43294210b 100644 --- a/kun/src/services/chromium-browser-cookies.ts +++ b/kun/src/services/chromium-browser-cookies.ts @@ -3,7 +3,7 @@ import { execFile } from 'node:child_process' import { accessSync, constants, readdirSync } from 'node:fs' import { access, copyFile, mkdtemp, rm } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' -import { join, win32 } from 'node:path' +import { join, posix, win32 } from 'node:path' import { promisify } from 'node:util' const execFileAsync = promisify(execFile) @@ -360,7 +360,7 @@ export function listChromiumCookieDatabaseCandidates( const environment = options.environment ?? process.env const userHome = options.homeDirectory ?? homedir() const browsers = options.browsers ?? OPENCODE_GO_CHROMIUM_BROWSERS - const joinPath = platform === 'win32' ? win32.join : join + const joinPath = platform === 'win32' ? win32.join : posix.join const roots: Array<{ browser: ChromiumBrowserCookieSource; root: string }> = [] for (const browser of browsers) { diff --git a/kun/src/services/extension-agent-service-core.ts b/kun/src/services/extension-agent-service-core.ts index 3e12c7ff4..979f2a70e 100644 --- a/kun/src/services/extension-agent-service-core.ts +++ b/kun/src/services/extension-agent-service-core.ts @@ -8,7 +8,7 @@ import type { import type { ExtensionProviderBinding } from '../contracts/extension-providers.js' import type { ModelReasoningEffort } from '../contracts/capabilities.js' import { TurnConflictError } from './turn-service.js' -import { bufferEvent, compareBufferedEvents, enqueueBufferedEvent, ExtensionBrokerError, iterateSessionEventsSince, ManifestExtensionAgentAuthorizer, summarizeRunEvents } from './extension-agent-service-event-usage.js' +import { bufferEvent, compareBufferedEvents, enqueueBufferedEvent, ExtensionBrokerError, iterateSessionEventsSince, loadLatestUsageTokens, ManifestExtensionAgentAuthorizer, summarizeRunEvents } from './extension-agent-service-event-usage.js' import { listExtensionRunEvents, pageExtensionOwnedThreads } from './extension-agent-service-listing.js' import { ManagedSubscription } from './extension-agent-service-subscription.js' import { clampBudget, completeBudget, decodeCursor, narrowToolScopes, normalizeOwnedWorkspace, opaqueNotFound, projectThread, runStatus, titleFromInput, validateBinding } from './extension-agent-service-projection.js' @@ -200,7 +200,7 @@ export class ExtensionAgentService { thread.turns.at(-1)?.model ?? thread.model, usesHostConnection ) - const tokenBaseline = await this.latestUsageTokens(thread.id) + const tokenBaseline = await loadLatestUsageTokens(this.options.sessions, thread.id) const started = await this.options.turns.startTurn({ threadId: thread.id, request: { @@ -648,16 +648,4 @@ export class ExtensionAgentService { } } - private async latestUsageTokens(threadId: string): Promise { - if (this.options.sessions.loadLatestUsageSnapshots) { - const snapshots = await this.options.sessions.loadLatestUsageSnapshots({ threadIds: [threadId] }) - const snapshot = snapshots.find((candidate) => candidate.threadId === threadId) - if (snapshot) return snapshot.usage.totalTokens - } - let totalTokens = 0 - for await (const event of iterateSessionEventsSince(this.options.sessions, threadId, -1)) { - if (event.kind === 'usage') totalTokens = event.usage.totalTokens - } - return totalTokens - } } diff --git a/kun/src/services/extension-agent-service-event-usage.ts b/kun/src/services/extension-agent-service-event-usage.ts index 576ad6a13..3c36d42ef 100644 --- a/kun/src/services/extension-agent-service-event-usage.ts +++ b/kun/src/services/extension-agent-service-event-usage.ts @@ -63,6 +63,19 @@ export async function* iterateSessionEventsSince( yield* sessions.iterateEventsSince(threadId, afterSeq, { maxRecordBytes: MAX_REPLAY_RECORD_BYTES }) } +export async function loadLatestUsageTokens(sessions: SessionStore, threadId: string): Promise { + if (sessions.loadLatestUsageSnapshots) { + const snapshots = await sessions.loadLatestUsageSnapshots({ threadIds: [threadId] }) + const snapshot = snapshots.find((candidate) => candidate.threadId === threadId) + if (snapshot) return snapshot.usage.totalTokens + } + let totalTokens = 0 + for await (const event of iterateSessionEventsSince(sessions, threadId, -1)) { + if (event.kind === 'usage') totalTokens = event.usage.totalTokens + } + return totalTokens +} + export async function summarizeRunEvents( sessions: SessionStore, threadId: string, diff --git a/kun/src/services/gateway-credential-service.test.ts b/kun/src/services/gateway-credential-service.test.ts index 319da500f..25f8ba059 100644 --- a/kun/src/services/gateway-credential-service.test.ts +++ b/kun/src/services/gateway-credential-service.test.ts @@ -26,8 +26,10 @@ describe('GatewayCredentialService', () => { const { key, created } = await service.ensure() expect(created).toBe(true) expect(key).toMatch(/^kun_local_[A-Za-z0-9_-]{43}$/) - expect((await stat(service.directory)).mode & 0o777).toBe(0o700) - expect((await stat(service.path)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') { + expect((await stat(service.directory)).mode & 0o777).toBe(0o700) + expect((await stat(service.path)).mode & 0o777).toBe(0o600) + } expect(await readFile(service.path, 'utf8')).not.toContain(key) }) diff --git a/kun/src/services/model-connection-registry.credential-fence.test.ts b/kun/src/services/model-connection-registry.credential-fence.test.ts index 087b756e4..0f0489fe2 100644 --- a/kun/src/services/model-connection-registry.credential-fence.test.ts +++ b/kun/src/services/model-connection-registry.credential-fence.test.ts @@ -116,6 +116,7 @@ async function registry( resolveCredentialSource?: ConstructorParameters[0]['resolveCredentialSource'], inspectCredentialSource?: ConstructorParameters[0]['inspectCredentialSource'], credentialFenceTtlMs?: number, + nowMs?: () => number, beforeCredentialFenceInstall?: ConstructorParameters< typeof ModelConnectionRegistry >[0]['beforeCredentialFenceInstall'], @@ -135,6 +136,7 @@ async function registry( ...(resolveCredentialSource ? { resolveCredentialSource } : {}), inspectCredentialSource: inspectCredentialSource ?? (async () => 'ready'), ...(credentialFenceTtlMs ? { credentialFenceTtlMs } : {}), + ...(nowMs ? { nowMs } : {}), ...(beforeCredentialFenceInstall ? { beforeCredentialFenceInstall } : {}), ...(afterCredentialCommitWrite ? { afterCredentialCommitWrite } : {}), onChanged: (connections) => { @@ -259,6 +261,7 @@ describe('ModelConnectionRegistry', () => { undefined, undefined, undefined, + undefined, afterCredentialCommitWrite ) const connected = await value.connect({ @@ -321,8 +324,15 @@ describe('ModelConnectionRegistry', () => { }) it('expires an abandoned prepared credential and restores the durable credential', async () => { - vi.useFakeTimers() - const { dataDir, value } = await registry(undefined, undefined, undefined, undefined, 25) + let now = 0 + const { dataDir, value } = await registry( + undefined, + undefined, + undefined, + undefined, + 60_000, + () => now + ) const connected = await value.connect({ expectedRevision: 0, id: 'deepseek', @@ -353,7 +363,7 @@ describe('ModelConnectionRegistry', () => { expect(await readFile(join(dataDir, 'model-connections.v1.json'), 'utf8')) .not.toContain('abandoned-plaintext') - await vi.advanceTimersByTimeAsync(25) + now += 60_000 await expect(value.resolveApiKey(sourceId)).resolves.toEqual({ apiKey: 'durable-secret' }) await expect(value.credentialStateForInternalConsumer('deepseek')).resolves.toEqual({ @@ -370,8 +380,15 @@ describe('ModelConnectionRegistry', () => { }) it('cancels an older expiry when a newer fence takes ownership', async () => { - vi.useFakeTimers() - const { value } = await registry(undefined, undefined, undefined, undefined, 25) + let now = 0 + const { value } = await registry( + undefined, + undefined, + undefined, + undefined, + 60_000, + () => now + ) const connected = await value.connect({ expectedRevision: 0, id: 'deepseek', @@ -399,20 +416,20 @@ describe('ModelConnectionRegistry', () => { credential: 'superseded-plaintext', operationToken: firstToken }) - await vi.advanceTimersByTimeAsync(20) + now += 50_000 await value.fenceCredential('deepseek', { expectedRevision: (await value.snapshot()).revision, operationToken: secondToken }) - await vi.advanceTimersByTimeAsync(5) + now += 10_000 await expect(value.resolveApiKey(sourceId)).resolves.toBeNull() await expect(value.commitPreparedCredential('deepseek', { expectedRevision: connected.revision, operationToken: firstToken })).rejects.toBeInstanceOf(ModelConnectionConflictError) - await vi.advanceTimersByTimeAsync(20) + now += 50_000 await expect(value.resolveApiKey(sourceId)).resolves.toEqual({ apiKey: 'durable-secret' }) }) diff --git a/kun/src/services/official-provider-cli.test.ts b/kun/src/services/official-provider-cli.test.ts index 8099c6dd7..818f7bac0 100644 --- a/kun/src/services/official-provider-cli.test.ts +++ b/kun/src/services/official-provider-cli.test.ts @@ -22,7 +22,9 @@ afterEach(async () => { describe('official provider CLI authentication', () => { it('does not return a bundled Gemini CLI command', () => { - expect(resolveGeminiCliCommand({ PATH: '' })?.args).toEqual([]) + const command = resolveGeminiCliCommand({ PATH: '' }) + expect(command?.args ?? []).toEqual([]) + if (command) expect(command.command).not.toBe(process.execPath) }) it('rejects an Antigravity download before extraction when its checksum is invalid', async () => { diff --git a/kun/src/services/turn-service-steering-operations.ts b/kun/src/services/turn-service-steering-operations.ts index c7182ed7b..0212cffa7 100644 --- a/kun/src/services/turn-service-steering-operations.ts +++ b/kun/src/services/turn-service-steering-operations.ts @@ -308,6 +308,9 @@ async interruptTurn(this: TurnService, input: { threadId: string; turnId: string } if (!transition) return { status: 'aborted' } + // Wake the local loop before publishing the terminal event. Event + // persistence may be queued behind the in-flight operation being aborted. + this.abortTurnExecution(input.turnId) try { await this['deps'].events.record({ kind: 'turn_aborted', diff --git a/kun/src/tui/connect-common.test.ts b/kun/src/tui/connect-common.test.ts new file mode 100644 index 000000000..48cbf5af2 --- /dev/null +++ b/kun/src/tui/connect-common.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { + connectionPresets, + connectionRequiresCredential +} from './connect-common.js' + +describe('TUI connection catalog', () => { + it('keeps OpenCore Free in the Free group and permits anonymous connection', () => { + const preset = connectionPresets.find((entry) => entry.id === 'opencode-free') + + expect(preset).toMatchObject({ + category: 'Free', + kind: 'http', + authType: 'api-key', + credentialRequirement: 'optional', + endpointFormat: 'chat_completions', + models: [ + 'big-pickle', + 'mimo-v2.5-free', + 'ling-3.0-flash-fin-free', + 'nemotron-3-ultra-free', + 'nemotron-3.5-lightning-free' + ] + }) + expect(connectionRequiresCredential(preset!)).toBe(false) + }) + + it('continues to require credentials for normal API providers', () => { + const preset = connectionPresets.find((entry) => entry.id === 'zenmux') + + expect(preset).toMatchObject({ + category: 'API', + credentialRequirement: 'required' + }) + expect(connectionRequiresCredential(preset!)).toBe(true) + }) +}) diff --git a/kun/src/tui/connect-common.ts b/kun/src/tui/connect-common.ts index 11608d357..6e27f31eb 100644 --- a/kun/src/tui/connect-common.ts +++ b/kun/src/tui/connect-common.ts @@ -20,6 +20,7 @@ import { providerCatalogEntries, type ProviderCatalogAuthFlow, type ProviderCatalogAuthType, + type ProviderCatalogCredentialRequirement, type ProviderCatalogKind } from '@kun/provider-catalog' import { spawn } from 'node:child_process' @@ -137,10 +138,11 @@ export type ConnectionPreset = { id: string presetSource?: string name: string - category: 'Subscription' | 'API' + category: 'Free' | 'Subscription' | 'API' kind: ProviderCatalogKind authFlow: ProviderCatalogAuthFlow authType: ProviderCatalogAuthType + credentialRequirement: ProviderCatalogCredentialRequirement baseUrl?: string endpointFormat: 'chat_completions' | 'responses' | 'messages' | 'custom_endpoint' models: string[] @@ -151,17 +153,20 @@ export type ConnectionPreset = { export const connectionPresets: ConnectionPreset[] = [ { id: 'custom', name: 'Custom provider', category: 'API', kind: 'http', - authFlow: 'api-key', authType: 'api-key', + authFlow: 'api-key', authType: 'api-key', credentialRequirement: 'required', endpointFormat: 'chat_completions', models: [] }, ...providerCatalogEntries().map((entry): ConnectionPreset => ({ id: entry.profileId, presetSource: entry.presetSource, name: entry.label, - category: entry.category === 'subscription' ? 'Subscription' : 'API', + category: entry.category === 'free' + ? 'Free' + : entry.category === 'subscription' ? 'Subscription' : 'API', kind: entry.kind, authFlow: entry.authFlow, authType: entry.authType, + credentialRequirement: entry.credentialRequirement, ...(entry.baseUrl ? { baseUrl: entry.baseUrl } : {}), endpointFormat: entry.endpointFormat, models: [...entry.models], @@ -193,6 +198,11 @@ export function credentialAvailabilityLabel( export const CONNECT_ENDPOINT_FORMATS = ['chat_completions', 'responses', 'messages', 'custom_endpoint'] as const export type ConnectField = 'id' | 'name' | 'baseUrl' | 'endpointFormat' | 'credential' | 'models' + +export function connectionRequiresCredential(preset: ConnectionPreset): boolean { + return preset.credentialRequirement === 'required' +} + export type ManagementAction = { kind: 'rename' | 'reconnect' | 'credential' | 'probe' | 'disconnect' | 'back' label: string diff --git a/kun/src/tui/connect-dialog-base.ts b/kun/src/tui/connect-dialog-base.ts index b92e47779..ff53059f0 100644 --- a/kun/src/tui/connect-dialog-base.ts +++ b/kun/src/tui/connect-dialog-base.ts @@ -526,10 +526,11 @@ export abstract class ConnectDialogBase implements Component, Focusable { entries.length > 14 ? ` ${dim(`${this.catalogIndex + 1}/${entries.length}`)}` : '' ) const subscriptions = entries.filter((entry) => entry.category === 'Subscription').length + const free = entries.filter((entry) => entry.category === 'Free').length const apis = entries.filter((entry) => entry.category === 'API' && entry.id !== 'custom').length return pageFrame({ path: ['KUN', 'Connect', 'Add provider'], - right: `${subscriptions} subscriptions · ${apis} APIs`, + right: `${free} free · ${subscriptions} subscriptions · ${apis} APIs`, description: 'Choose the same built-in provider catalog available in GUI, or define a compatible endpoint.', body: body.filter((line): line is string => line !== ''), footer: [ diff --git a/kun/src/tui/connect-dialog.ts b/kun/src/tui/connect-dialog.ts index 3f8579c5e..7378c4379 100644 --- a/kun/src/tui/connect-dialog.ts +++ b/kun/src/tui/connect-dialog.ts @@ -132,7 +132,7 @@ import { } from './clipboard-image.js' import { WorkspaceFileAutocompleteProvider } from './file-mentions.js' import { bold, dim, blue, cyan, green, yellow, red, magenta, italic, isCancelInput, EXIT_CONFIRM_WINDOW_MS, UNDO_ESCAPE_WINDOW_MS, TOTAL_ELAPSED_MIN_START_GAP_MS, BRACKETED_PASTE_START, BRACKETED_PASTE_END, ENABLE_MOUSE_TRACKING, DISABLE_MOUSE_TRACKING, DIRECT_SEMANTIC_ACTIONS, sanitizeTerminalText, selectTheme, editorTheme, markdownTheme, parseSgrMouseEvent, writeLocalShareSnapshot, removeLocalShareSnapshot, type SgrMouseEvent, type ExclusiveRouteHandle } from './pi-common.js' -import { authenticationStrategy, connectionPresetForProfile, credentialAvailabilityLabel, connectionPresets, managementActions, type ConnectionPreset, type ConnectField, type ManagementAction } from './connect-common.js' +import { authenticationStrategy, connectionPresetForProfile, connectionRequiresCredential, credentialAvailabilityLabel, connectionPresets, managementActions, type ConnectionPreset, type ConnectField, type ManagementAction } from './connect-common.js' import { ConnectDialogManagement } from './connect-dialog-management.js' import { endpointFormat, fieldLabel, isModelProbeFailure, normalizeConnectionProviderId, openBrowser } from './model-dialog.js' import { printableInput } from './render-utils.js' @@ -158,7 +158,8 @@ export class ConnectDialog extends ConnectDialogManagement { const customId = preset.id === 'custom' ? this.suggestCustomProviderId() : undefined this.fields = [ ...(preset.id === 'custom' ? ['id', 'name', 'baseUrl', 'endpointFormat'] as ConnectField[] : []), - ...(preset.kind === 'http' || preset.kind === 'cursor-sdk' + ...((preset.kind === 'http' || preset.kind === 'cursor-sdk') && + preset.credentialRequirement !== 'none' ? ['credential'] as ConnectField[] : []), 'models' @@ -348,7 +349,7 @@ export class ConnectDialog extends ConnectDialogManagement { if ( field === 'credential' && !trimmed && - (this.preset!.kind === 'http' || this.preset!.kind === 'cursor-sdk') + connectionRequiresCredential(this.preset!) ) { this.error = 'Credential is required and is never echoed or logged.' this.tui.requestRender() diff --git a/kun/src/tui/model-dialog.ts b/kun/src/tui/model-dialog.ts index eff4f196a..28d8a1c43 100644 --- a/kun/src/tui/model-dialog.ts +++ b/kun/src/tui/model-dialog.ts @@ -407,7 +407,10 @@ export function fieldLabel(field: ConnectField | undefined, preset: ConnectionPr case 'name': return 'Provider name' case 'baseUrl': return 'Base URL' case 'endpointFormat': return 'Endpoint format' - case 'credential': return preset.authType === 'oauth' ? 'OAuth credential' : 'API key / token plan key' + case 'credential': { + const label = preset.authType === 'oauth' ? 'OAuth credential' : 'API key / token plan key' + return preset.credentialRequirement === 'optional' ? `${label} (optional)` : label + } case 'models': return 'Models (comma separated)' default: return '' } diff --git a/kun/tests/adapter-cases/builtin-tools-1.cases.ts b/kun/tests/adapter-cases/builtin-tools-1.cases.ts index fc3ecaff3..5d71d4d09 100644 --- a/kun/tests/adapter-cases/builtin-tools-1.cases.ts +++ b/kun/tests/adapter-cases/builtin-tools-1.cases.ts @@ -51,6 +51,11 @@ import { import { createBackgroundShellTool } from '../../src/adapters/tool/background-shell-tool.js' +import { + listBashSessionRecords, + stopBashSessionById +} from '../../src/adapters/tool/builtin-bash-tool.js' + import { createReadTool as createReadToolFromModule } from '../../src/adapters/tool/read.js' import { createBashTool as createBashToolFromModule } from '../../src/adapters/tool/bash.js' @@ -143,8 +148,10 @@ beforeEach(async () => { }) afterEach(async () => { - await rm(workspace, { recursive: true, force: true }) - await rm(backgroundShellDataDir, { recursive: true, force: true }) + const sessions = await listBashSessionRecords() + await Promise.all(sessions.map((session) => stopBashSessionById(session.id, session.threadId))) + await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }) + await rm(backgroundShellDataDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }) }) it('advertises the pi-style built-in tool family by default', async () => { @@ -363,7 +370,7 @@ it('advertises structured GUI input choices and normalizes single-question optio const tools = await host.listTools( buildContext(workspace, { awaitUserInput: async () => ({ status: 'cancelled' }) }) ) - const requestInputTool = tools.find((tool) => tool.name === 'request_user_input') + const requestInputTool = tools.find((tool) => tool.name === 'user_input') expect(requestInputTool?.inputSchema).toMatchObject({ properties: { options: { type: 'array' }, @@ -405,11 +412,11 @@ it('advertises structured GUI input choices and normalizes single-question optio }) }) -it('keeps GUI input tools in the stable catalog without a user-input gate', async () => { +it('keeps the canonical GUI input tool in the stable catalog without a user-input gate', async () => { const tools = await host.listTools(buildContext(workspace)) const names = tools.map((tool) => tool.name) expect(names).toContain('user_input') - expect(names).toContain('request_user_input') + expect(names).not.toContain('request_user_input') }) it('exposes pi-style coding and read-only tool groups', () => { diff --git a/kun/tests/adapter-cases/builtin-tools-2.cases.ts b/kun/tests/adapter-cases/builtin-tools-2.cases.ts index b17370d4e..d8778fad6 100644 --- a/kun/tests/adapter-cases/builtin-tools-2.cases.ts +++ b/kun/tests/adapter-cases/builtin-tools-2.cases.ts @@ -51,6 +51,11 @@ import { import { createBackgroundShellTool } from '../../src/adapters/tool/background-shell-tool.js' +import { + listBashSessionRecords, + stopBashSessionById +} from '../../src/adapters/tool/builtin-bash-tool.js' + import { createReadTool as createReadToolFromModule } from '../../src/adapters/tool/read.js' import { createBashTool as createBashToolFromModule } from '../../src/adapters/tool/bash.js' @@ -143,8 +148,10 @@ beforeEach(async () => { }) afterEach(async () => { - await rm(workspace, { recursive: true, force: true }) - await rm(backgroundShellDataDir, { recursive: true, force: true }) + const sessions = await listBashSessionRecords() + await Promise.all(sessions.map((session) => stopBashSessionById(session.id, session.threadId))) + await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }) + await rm(backgroundShellDataDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }) }) it.skipIf(process.platform === 'win32')( diff --git a/kun/tests/adapter-cases/builtin-tools-3.cases.ts b/kun/tests/adapter-cases/builtin-tools-3.cases.ts index 033531c96..ea30bca29 100644 --- a/kun/tests/adapter-cases/builtin-tools-3.cases.ts +++ b/kun/tests/adapter-cases/builtin-tools-3.cases.ts @@ -51,6 +51,11 @@ import { import { createBackgroundShellTool } from '../../src/adapters/tool/background-shell-tool.js' +import { + listBashSessionRecords, + stopBashSessionById +} from '../../src/adapters/tool/builtin-bash-tool.js' + import { createReadTool as createReadToolFromModule } from '../../src/adapters/tool/read.js' import { createBashTool as createBashToolFromModule } from '../../src/adapters/tool/bash.js' @@ -143,8 +148,10 @@ beforeEach(async () => { }) afterEach(async () => { - await rm(workspace, { recursive: true, force: true }) - await rm(backgroundShellDataDir, { recursive: true, force: true }) + const sessions = await listBashSessionRecords() + await Promise.all(sessions.map((session) => stopBashSessionById(session.id, session.threadId))) + await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }) + await rm(backgroundShellDataDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }) }) it('coalesces slow background shell update notifications', async () => { diff --git a/kun/tests/adapter-cases/image-gen-tool-provider-1.cases.ts b/kun/tests/adapter-cases/image-gen-tool-provider-1.cases.ts index 100320774..057ee50b7 100644 --- a/kun/tests/adapter-cases/image-gen-tool-provider-1.cases.ts +++ b/kun/tests/adapter-cases/image-gen-tool-provider-1.cases.ts @@ -429,7 +429,11 @@ it('downloads a URL result from Seedream and surfaces provider errors', async () vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: { code: 'InvalidParameter', message: 'unsupported size' } }), { status: 200, headers: { 'content-type': 'application/json' } }))) - await expect(client.generate(request)).rejects.toThrow( + const errorClient = new VolcengineArkImageClient( + 'https://ark.cn-beijing.volces.com/api/v3', + 'ark-key' + ) + await expect(errorClient.generate(request)).rejects.toThrow( /Volcano Ark image provider returned no image data: unsupported size/ ) }) diff --git a/kun/tests/adapter-cases/media-gen-tool-provider-2.cases.ts b/kun/tests/adapter-cases/media-gen-tool-provider-2.cases.ts index 9788c76fc..f1606f30c 100644 --- a/kun/tests/adapter-cases/media-gen-tool-provider-2.cases.ts +++ b/kun/tests/adapter-cases/media-gen-tool-provider-2.cases.ts @@ -150,7 +150,11 @@ it('surfaces Seedance task failure details and obeys its timeout', async () => { headers: { 'content-type': 'application/json' } }) })) - await expect(client.generate({ + const timeoutClient = new VolcengineArkVideoClient( + 'https://ark.cn-beijing.volces.com/api/v3', + 'ark-key' + ) + await expect(timeoutClient.generate({ ...baseRequest, timeoutMs: 20 })).rejects.toThrow( @@ -204,7 +208,11 @@ it('rejects a successful Seedance task without an output URL and stops on caller }) })) const controller = new AbortController() - const generation = client.generate({ + const abortClient = new VolcengineArkVideoClient( + 'https://ark.cn-beijing.volces.com/api/v3', + 'ark-key' + ) + const generation = abortClient.generate({ ...request, pollIntervalMs: 1_000, signal: controller.signal diff --git a/kun/tests/adapter-cases/model-client-6.cases.ts b/kun/tests/adapter-cases/model-client-6.cases.ts index 9974fdc93..bdddabac1 100644 --- a/kun/tests/adapter-cases/model-client-6.cases.ts +++ b/kun/tests/adapter-cases/model-client-6.cases.ts @@ -149,7 +149,8 @@ it('fails a streamed response that goes idle without DONE', async () => { apiKey: 'k', model: 'deepseek-chat', fetchImpl, - streamIdleTimeoutMs: 5 + streamIdleTimeoutMs: 5, + retry: { maxAttempts: 0 } }) const chunks = [] for await (const chunk of client.stream(buildRequest(new AbortController().signal))) { diff --git a/kun/tests/builtin-profiles.test.ts b/kun/tests/builtin-profiles.test.ts index b2dd1b581..abfa8e495 100644 --- a/kun/tests/builtin-profiles.test.ts +++ b/kun/tests/builtin-profiles.test.ts @@ -29,13 +29,13 @@ describe('mergeBuiltinSubagentProfiles', () => { profiles: { mine: { mode: 'subagent', toolPolicy: 'readOnly' } } }) const merged = mergeBuiltinSubagentProfiles(config) - expect(Object.keys(BUILTIN_SUBAGENT_PROFILES)).toHaveLength(45) + expect(Object.keys(BUILTIN_SUBAGENT_PROFILES)).toHaveLength(46) expect(Object.keys(merged.profiles).sort()).toEqual([...Object.keys(BUILTIN_SUBAGENT_PROFILES), 'mine'].sort()) }) it('uses the canonical catalog for every runtime display and default policy field', () => { - expect(BUILTIN_AGENT_CATALOG).toHaveLength(45) - expect(new Set(BUILTIN_AGENT_CATALOG.map((entry) => entry.id)).size).toBe(45) + expect(BUILTIN_AGENT_CATALOG).toHaveLength(46) + expect(new Set(BUILTIN_AGENT_CATALOG.map((entry) => entry.id)).size).toBe(46) expect(Object.keys(BUILTIN_SUBAGENT_PROFILES).sort()).toEqual( BUILTIN_AGENT_CATALOG.map((entry) => entry.id).sort() ) @@ -97,7 +97,7 @@ describe('mergeBuiltinSubagentProfiles', () => { }) it('keeps every fixed builtin standalone, skill-free, and unable to recurse', () => { - expect(Object.keys(BUILTIN_SUBAGENT_PROFILES)).toHaveLength(45) + expect(Object.keys(BUILTIN_SUBAGENT_PROFILES)).toHaveLength(46) for (const [id, profile] of Object.entries(BUILTIN_SUBAGENT_PROFILES)) { expect(profile.systemPrompt, `${id} systemPrompt`).toBeTruthy() expect(profile.skillsEnabled, `${id} skillsEnabled`).toBe(false) diff --git a/kun/tests/create-plan-tool.test.ts b/kun/tests/create-plan-tool.test.ts index fecf55baf..12870d80f 100644 --- a/kun/tests/create-plan-tool.test.ts +++ b/kun/tests/create-plan-tool.test.ts @@ -11,6 +11,7 @@ import { executeCreatePlanTool, isPlanToolContextActive } from '../src/adapters/tool/create-plan-tool.js' +import { buildGuiPlanId } from '../src/shared/gui-plan.js' import type { ToolHostContext } from '../src/ports/tool-host.js' function buildContext(overrides: Partial = {}): ToolHostContext { @@ -98,7 +99,6 @@ describe('create_plan tool: advertisement', () => { 'repo_map', 'git_inspect', 'user_input', - 'request_user_input', CREATE_PLAN_TOOL_NAME ]) expect(names).not.toEqual(expect.arrayContaining(['write', 'edit', 'bash', 'verify_changes', 'echo'])) @@ -118,7 +118,6 @@ describe('create_plan tool: advertisement', () => { 'repo_map', 'git_inspect', 'user_input', - 'request_user_input', CREATE_PLAN_TOOL_NAME ]) }) @@ -539,7 +538,7 @@ describe('create_plan tool: success and atomic write', () => { expect(result.isError).toBeFalsy() expect(result.output).toMatchObject({ relative_path: '.kunsdd/plan/thr-plan-fallback.md', - plan_id: `${workspace}:.kunsdd/plan/thr-plan-fallback.md` + plan_id: buildGuiPlanId(workspace, '.kunsdd/plan/thr-plan-fallback.md') }) }) diff --git a/kun/tests/loop-cases/agent-loop-interruption-core.cases.ts b/kun/tests/loop-cases/agent-loop-interruption-core.cases.ts index 205293cff..56d318cf3 100644 --- a/kun/tests/loop-cases/agent-loop-interruption-core.cases.ts +++ b/kun/tests/loop-cases/agent-loop-interruption-core.cases.ts @@ -397,8 +397,8 @@ describe('AgentLoop interruption', () => { expect(modelRequestContextText(model.requests[1]!)).toContain('did not call `graph_define_plan`') expect(model.requests[2]?.requiredToolName).toBeUndefined() expect(model.requests[2]?.tools.map((tool) => tool.name)).toEqual([ - 'graph_define_plan', 'graph_control_run', + 'graph_define_plan', 'graph_supervise_node' ]) expect(modelRequestContextText(model.requests[2]!)).toContain( diff --git a/kun/tests/loop-cases/agent-loop-interruption-graph.cases.ts b/kun/tests/loop-cases/agent-loop-interruption-graph.cases.ts index c7641c092..98aff928c 100644 --- a/kun/tests/loop-cases/agent-loop-interruption-graph.cases.ts +++ b/kun/tests/loop-cases/agent-loop-interruption-graph.cases.ts @@ -268,8 +268,11 @@ describe('AgentLoop interruption', () => { expect(parkedItems).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: 'error', - code: 'stream_read_error', - message: 'model stream read failed: terminated' + code: 'stream_disconnected', + details: expect.objectContaining({ + rawCode: 'stream_read_error', + rawMessage: 'model stream read failed: terminated' + }) }) ])) expect(parkedItems.filter((item) => diff --git a/kun/tests/loop-cases/loop-agent-compaction-core.cases.ts b/kun/tests/loop-cases/loop-agent-compaction-core.cases.ts index 00cd85475..0be652dd7 100644 --- a/kun/tests/loop-cases/loop-agent-compaction-core.cases.ts +++ b/kun/tests/loop-cases/loop-agent-compaction-core.cases.ts @@ -135,7 +135,7 @@ describe('AgentLoop', () => { expect(status).toBe('completed') expect(observedRequiredToolNames).toEqual([undefined, undefined, undefined]) - await expect(readFile(join(workspace, '.kunsdd/plan/auth.md'), 'utf8')).resolves.toBe( + await expect(readFile(join(workspace, '.kunsdd/plan/thr-1.md'), 'utf8')).resolves.toBe( '## Plan\nImplement auth after checking context.' ) } finally { diff --git a/kun/tests/loop-cases/loop-agent-plans.cases.ts b/kun/tests/loop-cases/loop-agent-plans.cases.ts index c00e75c95..4740eeb18 100644 --- a/kun/tests/loop-cases/loop-agent-plans.cases.ts +++ b/kun/tests/loop-cases/loop-agent-plans.cases.ts @@ -313,7 +313,7 @@ describe('AgentLoop', () => { expect(status).toBe('completed') expect(observedToolLists[0]).toContain(CREATE_PLAN_TOOL_NAME) expect(observedRequiredToolNames).toEqual([undefined, undefined]) - await expect(readFile(join(workspace, '.kunsdd/plan/auth.md'), 'utf8')).resolves.toBe('# Generated plan') + await expect(readFile(join(workspace, '.kunsdd/plan/thr-1.md'), 'utf8')).resolves.toBe('# Generated plan') const turn = await h.turns.getTurn(h.threadId, h.turnId) expect(turn?.guiPlan?.relativePath).toBe('.kunsdd/plan/auth.md') const items = await h.sessionStore.loadItems(h.threadId) @@ -322,7 +322,7 @@ describe('AgentLoop', () => { if (result?.kind === 'tool_result') { expect(result.toolName).toBe(CREATE_PLAN_TOOL_NAME) expect(result.output).toMatchObject({ - relative_path: '.kunsdd/plan/auth.md', + relative_path: '.kunsdd/plan/thr-1.md', workspace_root: workspace, operation: 'draft' }) @@ -364,7 +364,7 @@ describe('AgentLoop', () => { const items = await h.sessionStore.loadItems(h.threadId) expect(status).toBe('completed') - await expect(readFile(join(workspace, '.kunsdd/plan/auth.md'), 'utf8')).resolves.toBe( + await expect(readFile(join(workspace, '.kunsdd/plan/thr-1.md'), 'utf8')).resolves.toBe( '## Plan\nImplement auth.' ) expect(items.some((item) => diff --git a/kun/tests/loop-test-harness.ts b/kun/tests/loop-test-harness.ts index e4576c7f3..7b17f9fec 100644 --- a/kun/tests/loop-test-harness.ts +++ b/kun/tests/loop-test-harness.ts @@ -27,6 +27,7 @@ import type { ContextCompactionConfig } from '../src/loop/model-context-profile. import type { ResolvedHook } from '../src/hooks/hook-engine.js' import type { AgentSdkRuntime } from '../src/runtime/agent-sdk/agent-sdk-runtime.js' import type { ApprovalReviewPort } from '../src/ports/approval-review.js' +import { COMPACTION_SYSTEM_PROMPT } from '../src/loop/compaction-summary.js' export type Harness = { threadId: string @@ -67,6 +68,7 @@ export function makeSilentModel(): ModelClient { provider: 'silent', model: 'silent', async *stream(): AsyncIterable { + yield { kind: 'assistant_reasoning_delta', text: 'internal test reasoning' } yield { kind: 'completed', stopReason: 'stop' } } } @@ -135,7 +137,7 @@ export function makeHarness( sessionStore, approvalGate, userInputGate, - model, + model: withValidTerminalResponse(model), toolHost, usage, events, @@ -187,6 +189,38 @@ export function makeHarness( } } +function withValidTerminalResponse(model: ModelClient): ModelClient { + const stream = async function* (request: ModelRequest): AsyncIterable { + let hasOutput = false + for await (const chunk of model.stream(request)) { + if ( + chunk.kind === 'assistant_text_delta' || + chunk.kind === 'assistant_reasoning_delta' || + chunk.kind === 'tool_call_complete' || + chunk.kind === 'image_generation_complete' + ) { + hasOutput = true + } + if ( + chunk.kind === 'completed' && + chunk.stopReason === 'stop' && + !hasOutput && + request.systemPrompt !== COMPACTION_SYSTEM_PROMPT + ) { + yield { kind: 'assistant_reasoning_delta', text: 'internal test completion' } + hasOutput = true + } + yield chunk + } + } + + return new Proxy(model, { + get(target, property, receiver) { + return property === 'stream' ? stream : Reflect.get(target, property, receiver) + } + }) +} + export async function bootstrapThread( h: Harness, options: { diff --git a/kun/tests/review.test.ts b/kun/tests/review.test.ts index 9e205b1da..3c4f0bb8f 100644 --- a/kun/tests/review.test.ts +++ b/kun/tests/review.test.ts @@ -17,7 +17,12 @@ const execFileAsync = promisify(execFile) const temporaryDirectories: string[] = [] afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true }))) + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 100 + }))) }) describe('review contracts', () => { diff --git a/kun/tests/server-cases/graph-runtime-creation-authority.cases.ts b/kun/tests/server-cases/graph-runtime-creation-authority.cases.ts index cd0513385..6bfbf0d56 100644 --- a/kun/tests/server-cases/graph-runtime-creation-authority.cases.ts +++ b/kun/tests/server-cases/graph-runtime-creation-authority.cases.ts @@ -24,7 +24,7 @@ const roots: string[] = [] afterEach(async () => { await Promise.all(roots.splice(0).map((root) => - rm(root, { recursive: true, force: true }))) + rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }))) }) async function transitionRun( diff --git a/kun/tests/server-cases/graph-runtime-creation-recovery.cases.ts b/kun/tests/server-cases/graph-runtime-creation-recovery.cases.ts index b2d6e5990..8dd2fc14a 100644 --- a/kun/tests/server-cases/graph-runtime-creation-recovery.cases.ts +++ b/kun/tests/server-cases/graph-runtime-creation-recovery.cases.ts @@ -24,7 +24,7 @@ const roots: string[] = [] afterEach(async () => { await Promise.all(roots.splice(0).map((root) => - rm(root, { recursive: true, force: true }))) + rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }))) }) async function transitionRun( diff --git a/kun/tests/server-cases/graph-runtime-terminal-live.cases.ts b/kun/tests/server-cases/graph-runtime-terminal-live.cases.ts index f9063858b..6cfe89584 100644 --- a/kun/tests/server-cases/graph-runtime-terminal-live.cases.ts +++ b/kun/tests/server-cases/graph-runtime-terminal-live.cases.ts @@ -24,7 +24,7 @@ const roots: string[] = [] afterEach(async () => { await Promise.all(roots.splice(0).map((root) => - rm(root, { recursive: true, force: true }))) + rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }))) }) async function transitionRun( diff --git a/kun/tests/server-cases/graph-runtime-terminal-settlement.cases.ts b/kun/tests/server-cases/graph-runtime-terminal-settlement.cases.ts index 034cf8a5e..d19e3e67c 100644 --- a/kun/tests/server-cases/graph-runtime-terminal-settlement.cases.ts +++ b/kun/tests/server-cases/graph-runtime-terminal-settlement.cases.ts @@ -24,7 +24,7 @@ const roots: string[] = [] afterEach(async () => { await Promise.all(roots.splice(0).map((root) => - rm(root, { recursive: true, force: true }))) + rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }))) }) async function transitionRun( diff --git a/kun/tests/skill-tool-provider.test.ts b/kun/tests/skill-tool-provider.test.ts index 1363919d5..590e6bcc1 100644 --- a/kun/tests/skill-tool-provider.test.ts +++ b/kun/tests/skill-tool-provider.test.ts @@ -153,7 +153,9 @@ describe('buildSkillToolProviders', () => { awaitApproval: async () => 'allow' as const } - expect((await host.listTools(baseContext)).map((spec) => spec.name)).toEqual(['load_skill']) + expect((await host.listTools(baseContext)).map((spec) => spec.name)).toEqual([ + 'load_skill', 'load_skill_asset' + ]) await host.execute({ callId: 'call_load', toolName: 'load_skill', @@ -168,7 +170,7 @@ describe('buildSkillToolProviders', () => { }) const nextContext = { ...baseContext, activeSkillIds: nextResolution.activeSkillIds } expect((await host.listTools(nextContext)).map((spec) => spec.name)).toEqual([ - 'load_skill', 'demo_run' + 'load_skill', 'load_skill_asset', 'demo_run' ]) await expect(host.execute({ callId: 'call_run', diff --git a/kun/tests/top-level-cases/http-server-threads.cases.ts b/kun/tests/top-level-cases/http-server-threads.cases.ts index b6fe007f4..570c10af3 100644 --- a/kun/tests/top-level-cases/http-server-threads.cases.ts +++ b/kun/tests/top-level-cases/http-server-threads.cases.ts @@ -248,7 +248,7 @@ describe('HTTP server', () => { expect(limitedBody.threads).toHaveLength(1) }) - it('returns the complete history when the caller omits a limit', async () => { + it('returns the default first page when the caller omits a limit', async () => { const h = buildHarness() await Promise.all(Array.from({ length: 501 }, (_, index) => h.threadService.create( @@ -264,9 +264,16 @@ describe('HTTP server', () => { }) ) expect(response.status).toBe(200) - const body = (await readJson(response)) as { threads: Array<{ id: string }> } - expect(body.threads).toHaveLength(501) - expect(new Set(body.threads.map((thread) => thread.id)).size).toBe(501) + const body = (await readJson(response)) as { + threads: Array<{ id: string }> + hasMore?: boolean + total?: number + nextCursor?: string + } + expect(body.threads).toHaveLength(100) + expect(new Set(body.threads.map((thread) => thread.id)).size).toBe(100) + expect(body).toMatchObject({ hasMore: true, total: 501 }) + expect(body.nextCursor).toBeTruthy() }) it('deletes threads through the HTTP layer', async () => { diff --git a/kun/tests/top-level-cases/http-server-turns-sse.cases.ts b/kun/tests/top-level-cases/http-server-turns-sse.cases.ts index 0f8cfc0aa..c158f9ae8 100644 --- a/kun/tests/top-level-cases/http-server-turns-sse.cases.ts +++ b/kun/tests/top-level-cases/http-server-turns-sse.cases.ts @@ -371,7 +371,9 @@ describe('HTTP server', () => { ) const events = await readSseEvents(eventStream) - expect(events).toEqual([]) + expect(events).toHaveLength(1) + expect(events[0]).toContain('event: replay_synchronized') + expect(events[0]).toContain(`"cursor":${latestSeq}`) expect(loadEventsSince).not.toHaveBeenCalled() }) }) diff --git a/kun/tests/top-level-cases/runtime-factory-usage.cases.ts b/kun/tests/top-level-cases/runtime-factory-usage.cases.ts index 44d332231..05dd4f31f 100644 --- a/kun/tests/top-level-cases/runtime-factory-usage.cases.ts +++ b/kun/tests/top-level-cases/runtime-factory-usage.cases.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { SUPPORTED_EXTENSION_API_VERSIONS } from '@kun/extension-api' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -113,7 +114,7 @@ describe('runtime factory usage carryover', () => { expect(runtime.extensionPlatform).toBeDefined() expect(runtime.info().extensions).toMatchObject({ enabled: true, - apiVersions: ['1.2.0', '1.1.0', '1.0.0'], + apiVersions: [...SUPPORTED_EXTENSION_API_VERSIONS], manifestVersions: [1] }) expect(runtime.info().capabilities.instructions.enabled).toBe(true) diff --git a/kun/tests/top-level-cases/thread-service-state.cases.ts b/kun/tests/top-level-cases/thread-service-state.cases.ts index dd829a37f..e36275203 100644 --- a/kun/tests/top-level-cases/thread-service-state.cases.ts +++ b/kun/tests/top-level-cases/thread-service-state.cases.ts @@ -123,7 +123,7 @@ describe('ThreadService todos', () => { mode: 'document_edit' }) expect(rewritten.items.find((item) => item.content === 'Build UI')).toBeUndefined() - expect(rewritten.items.find((item) => item.content === 'Add tests')?.status).toBe('completed') + expect(rewritten.items.find((item) => item.content === 'Add tests')?.status).toBe('pending') const events = await sessionStore.loadEventsSince('thr_todos', 0) expect(events.some((event) => event.kind === 'todos_updated')).toBe(true) diff --git a/kun/tests/tui-cases/client-streaming.cases.ts b/kun/tests/tui-cases/client-streaming.cases.ts index ac8f22ab0..49813c4ee 100644 --- a/kun/tests/tui-cases/client-streaming.cases.ts +++ b/kun/tests/tui-cases/client-streaming.cases.ts @@ -75,6 +75,7 @@ function options(overrides: Partial = {}): TuiOptions { function modelSnapshot(revision = 1) { return { schemaVersion: 1 as const, + proxyRoutingVersion: 1 as const, revision, providers: [{ id: 'provider-a', accountId: 'account:provider-a', name: 'Provider A', diff --git a/kun/tests/tui-cases/pi-app-attachments.cases.ts b/kun/tests/tui-cases/pi-app-attachments.cases.ts index 255a2d33b..a888a7943 100644 --- a/kun/tests/tui-cases/pi-app-attachments.cases.ts +++ b/kun/tests/tui-cases/pi-app-attachments.cases.ts @@ -468,9 +468,9 @@ describe("PiTuiApplication clipboard, attachments, and streaming", () => { expect(outputText).not.toContain('private thought') const beforePointerMode = outputText.length - input.emit('data', '\x18') - input.emit('data', 'p') - await waitFor(() => outputText.slice(beforePointerMode).includes('Mouse clicks enabled')) + input.emit('data', '\x18p') + expect((app as unknown as { pointerModeEnabled: boolean }).pointerModeEnabled).toBe(true) + await waitFor(() => outputText.slice(beforePointerMode).includes('\x1b[?1000h\x1b[?1006h')) expect(outputText.slice(beforePointerMode)).toContain('\x1b[?1000h\x1b[?1006h') const beforeMouseExpand = outputText.length @@ -487,26 +487,24 @@ describe("PiTuiApplication clipboard, attachments, and streaming", () => { expect(outputText.slice(beforeMouseCollapse)).not.toContain('private thought') const beforeTextSelection = outputText.length - input.emit('data', '\x18') - input.emit('data', 'p') - await waitFor(() => outputText.slice(beforeTextSelection).includes('Text selection mode')) + input.emit('data', '\x18p') + expect((app as unknown as { pointerModeEnabled: boolean }).pointerModeEnabled).toBe(false) + await waitFor(() => outputText.slice(beforeTextSelection).includes('\x1b[?1000l\x1b[?1006l')) expect(outputText.slice(beforeTextSelection)).toContain('\x1b[?1000l\x1b[?1006l') expect(controller.state.projection?.runningTurnId).toBe('turn_stream') const beforeClicksRestored = outputText.length - input.emit('data', '\x18') - input.emit('data', 'p') - await waitFor(() => outputText.slice(beforeClicksRestored).includes('Mouse clicks enabled')) + input.emit('data', '\x18p') + expect((app as unknown as { pointerModeEnabled: boolean }).pointerModeEnabled).toBe(true) + await waitFor(() => outputText.slice(beforeClicksRestored).includes('\x1b[?1000h\x1b[?1006h')) expect(outputText.slice(beforeClicksRestored)).toContain('\x1b[?1000h\x1b[?1006h') const beforeExpand = outputText.length type(input, '/thinking') - await waitFor(() => outputText.slice(beforeExpand).includes('Thinking is expanded')) await waitFor(() => outputText.slice(beforeExpand).includes('private thought')) const beforeCollapse = outputText.length type(input, '/thinking') - await waitFor(() => outputText.slice(beforeCollapse).includes('Thinking is collapsed')) onEvent?.({ ...base, kind: 'assistant_reasoning_delta', seq: 4, itemId: 'reason_stream', item: { diff --git a/kun/tests/tui-cases/pi-app-provider-auth.cases.ts b/kun/tests/tui-cases/pi-app-provider-auth.cases.ts index 04d37244f..28cd11703 100644 --- a/kun/tests/tui-cases/pi-app-provider-auth.cases.ts +++ b/kun/tests/tui-cases/pi-app-provider-auth.cases.ts @@ -244,8 +244,9 @@ describe("PiTuiApplication shared provider authentication", () => { const current = detail() const initial = modelSnapshot() const catalog = providerCatalogEntries() - const subscriptionCount = catalog.filter((entry) => entry.authType !== 'api-key').length - const apiCount = catalog.filter((entry) => entry.authType === 'api-key').length + const freeCount = catalog.filter((entry) => entry.category === 'free').length + const subscriptionCount = catalog.filter((entry) => entry.category === 'subscription').length + const apiCount = catalog.filter((entry) => entry.category === 'api').length const grokProfile = { id: 'grok-subscription', accountId: 'account:grok-subscription', @@ -322,6 +323,7 @@ describe("PiTuiApplication shared provider authentication", () => { await waitFor(() => sanitizeTerminalText(outputText).includes('KUN / Connect')) input.emit('data', '\r') await waitFor(() => + outputText.includes(`${freeCount} free`) && outputText.includes(`${subscriptionCount} subscriptions`) && outputText.includes(`${apiCount} APIs`) && outputText.includes('Google Antigravity 订阅') && @@ -423,7 +425,7 @@ describe("PiTuiApplication shared provider authentication", () => { expect(completeModelCliAuth).toHaveBeenCalledWith({ expectedRevision: initial.revision, provider: 'gemini-cli', - model: 'gemini-3.1-pro-preview', + model: 'gemini-3.7-pro-preview', select: true }) await waitFor(() => diff --git a/kun/tests/user-input-disabled.test.ts b/kun/tests/user-input-disabled.test.ts index 7f64fc577..1ba6195f5 100644 --- a/kun/tests/user-input-disabled.test.ts +++ b/kun/tests/user-input-disabled.test.ts @@ -37,7 +37,7 @@ describe('agent loop: disableUserInput turns (IM bridges)', () => { for (const request of seenRequests) { const advertised = request.tools.map((tool) => tool.name) expect(advertised).toContain('user_input') - expect(advertised).toContain('request_user_input') + expect(advertised).not.toContain('request_user_input') expect(modelRequestContextText(request)).toMatch(/Do not call either tool/) } @@ -69,7 +69,7 @@ describe('agent loop: disableUserInput turns (IM bridges)', () => { expect(status).toBe('completed') const advertised = seenRequests[0]?.tools.map((tool) => tool.name) ?? [] expect(advertised).toContain('user_input') - expect(advertised).toContain('request_user_input') + expect(advertised).not.toContain('request_user_input') expect(modelRequestContextText(seenRequests[0]!)).not.toMatch( /Do not call either tool/ ) diff --git a/packages/provider-catalog/src/index.test.ts b/packages/provider-catalog/src/index.test.ts index 36ec19a61..94c6b3d6d 100644 --- a/packages/provider-catalog/src/index.test.ts +++ b/packages/provider-catalog/src/index.test.ts @@ -9,10 +9,27 @@ import { describe('provider catalog', () => { it('publishes every GUI base preset and Token Plan as stable entries', () => { const entries = providerCatalogEntries() - expect(PROVIDER_CATALOG).toHaveLength(24) - expect(entries).toHaveLength(29) + expect(PROVIDER_CATALOG).toHaveLength(25) + expect(entries).toHaveLength(30) expect(entries.filter((entry) => entry.category === 'subscription')).toHaveLength(18) expect(entries.filter((entry) => entry.category === 'api')).toHaveLength(11) + expect(entries.filter((entry) => entry.category === 'free')).toEqual([ + expect.objectContaining({ + profileId: 'opencode-free', + authType: 'api-key', + credentialRequirement: 'optional', + endpointFormat: 'chat_completions', + models: [ + 'big-pickle', + 'mimo-v2.5-free', + 'ling-3.0-flash-fin-free', + 'nemotron-3-ultra-free', + 'nemotron-3.5-lightning-free' + ] + }) + ]) + expect(entries.find((entry) => entry.profileId === 'zenmux')) + .toMatchObject({ credentialRequirement: 'required' }) expect(entries.map((entry) => entry.profileId)).toEqual(expect.arrayContaining([ 'gemini-subscription', 'gemini-cli-subscription', diff --git a/packages/provider-catalog/src/index.ts b/packages/provider-catalog/src/index.ts index 1ff199771..c0236b6d9 100644 --- a/packages/provider-catalog/src/index.ts +++ b/packages/provider-catalog/src/index.ts @@ -1,8 +1,10 @@ +import { OPENCODE_FREE_MODELS } from './opencode-free-models.js' + export * from './antigravity-model-catalog.js' export const TOKEN_PLAN_PROVIDER_ID_SUFFIX = '-token-plan' -export type ProviderCatalogCategory = 'api' | 'subscription' +export type ProviderCatalogCategory = 'api' | 'free' | 'subscription' export type ProviderCatalogKind = | 'http' | 'agent-sdk' @@ -18,6 +20,7 @@ export type ProviderCatalogAuthFlow = | 'gemini-cli-subscription' | 'cursor-api-key' export type ProviderCatalogAuthType = 'api-key' | 'oauth' | 'subscription' +export type ProviderCatalogCredentialRequirement = 'required' | 'optional' | 'none' export type ProviderCatalogEndpointFormat = | 'chat_completions' | 'responses' @@ -41,6 +44,7 @@ export type ProviderCatalogPreset = { kind: ProviderCatalogKind authFlow: ProviderCatalogAuthFlow authType: ProviderCatalogAuthType + credentialRequirement?: ProviderCatalogCredentialRequirement baseUrl: string endpointFormat: ProviderCatalogEndpointFormat models: readonly string[] @@ -60,6 +64,7 @@ export type ProviderCatalogEntry = { kind: ProviderCatalogKind authFlow: ProviderCatalogAuthFlow authType: ProviderCatalogAuthType + credentialRequirement: ProviderCatalogCredentialRequirement baseUrl: string endpointFormat: ProviderCatalogEndpointFormat models: readonly string[] @@ -292,7 +297,7 @@ export const PROVIDER_CATALOG = [ authType: 'subscription', baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4/chat/completions', endpointFormat: 'custom_endpoint', - models: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7', 'glm-4.5-air'], + models: ['glm-5.3', 'glm-5.3-flash', 'glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7', 'glm-4.5-air'], docsUrl: 'https://docs.bigmodel.cn/cn/coding-plan/overview', credentialUrl: 'https://bigmodel.cn/usercenter/proj-mgmt/apikeys' }, @@ -305,7 +310,7 @@ export const PROVIDER_CATALOG = [ authType: 'subscription', baseUrl: 'https://api.z.ai/api/coding/paas/v4/chat/completions', endpointFormat: 'custom_endpoint', - models: ['glm-5.2', 'glm-5.1', 'glm-5', 'glm-5-turbo', 'glm-4.7', 'glm-4.5-air'], + models: ['glm-5.3', 'glm-5.2', 'glm-5.1', 'glm-5', 'glm-5-turbo', 'glm-4.7', 'glm-4.5-air'], docsUrl: 'https://docs.z.ai/devpack/tool/others', credentialUrl: 'https://z.ai/subscribe' }, @@ -362,6 +367,20 @@ export const PROVIDER_CATALOG = [ docsUrl: 'https://www.volcengine.com/docs/82379/1928262', credentialUrl: 'https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey' }, + { + id: 'opencode-free', + name: 'OpenCore Free', + category: 'free', + kind: 'http', + authFlow: 'api-key', + authType: 'api-key', + credentialRequirement: 'optional', + baseUrl: 'https://opencode.ai/zen/v1', + endpointFormat: 'chat_completions', + models: OPENCODE_FREE_MODELS, + docsUrl: 'https://opencode.ai/docs/zen/', + credentialUrl: 'https://opencode.ai/docs/zen/' + }, { id: 'opencode-go', name: 'OpenCode Go', @@ -624,6 +643,7 @@ export function providerCatalogEntries(): ProviderCatalogEntry[] { kind: preset.kind, authFlow: preset.authFlow, authType: preset.authType, + credentialRequirement: preset.credentialRequirement ?? 'required', baseUrl: preset.baseUrl, endpointFormat: preset.endpointFormat, models: [...preset.models], @@ -645,6 +665,7 @@ export function providerCatalogEntries(): ProviderCatalogEntry[] { kind: 'http', authFlow: 'api-key', authType: 'subscription', + credentialRequirement: 'required', baseUrl: preset.tokenPlan.baseUrl, endpointFormat: preset.tokenPlan.endpointFormat, models: [...preset.tokenPlan.models], @@ -654,6 +675,7 @@ export function providerCatalogEntries(): ProviderCatalogEntry[] { ] }) return [ + ...entries.filter((entry) => entry.category === 'free'), ...entries.filter((entry) => entry.category === 'subscription'), ...entries.filter((entry) => entry.category === 'api') ] diff --git a/packages/provider-catalog/src/opencode-free-models.ts b/packages/provider-catalog/src/opencode-free-models.ts new file mode 100644 index 000000000..0a31b49f1 --- /dev/null +++ b/packages/provider-catalog/src/opencode-free-models.ts @@ -0,0 +1,7 @@ +export const OPENCODE_FREE_MODELS = [ + 'big-pickle', + 'mimo-v2.5-free', + 'ling-3.0-flash-fin-free', + 'nemotron-3-ultra-free', + 'nemotron-3.5-lightning-free' +] as const diff --git a/scripts/check-windows-installer-syntax.cjs b/scripts/check-windows-installer-syntax.cjs index c89e4d025..987bfa1b2 100644 --- a/scripts/check-windows-installer-syntax.cjs +++ b/scripts/check-windows-installer-syntax.cjs @@ -16,6 +16,7 @@ const installerHelperPaths = [ 'windows-installer-migration-journal.ps1', 'windows-installer-migration-filesystem.ps1', 'windows-installer-migration-actions.ps1', + 'windows-installer-migration-recovery-env.ps1', 'windows-installer-migration-transaction.ps1' ].map((path) => path === installerHelperPath ? path : join(__dirname, '..', 'build', path)) diff --git a/scripts/fixtures/update-handoff-owner.cjs b/scripts/fixtures/update-handoff-owner.cjs index 3581a5978..59b45db96 100644 --- a/scripts/fixtures/update-handoff-owner.cjs +++ b/scripts/fixtures/update-handoff-owner.cjs @@ -14,7 +14,9 @@ function argument(name) { } async function main() { - const dataDir = argument('--data-dir') + const dataDir = argument(process.argv.includes('--fixture-data-dir') + ? '--fixture-data-dir' + : '--data-dir') const scenario = argument('--scenario') const buildId = argument('--build-id') const discoveryPath = join(dataDir, 'runtime.json') diff --git a/scripts/smoke-packaged-update-handoff-recycled.cjs b/scripts/smoke-packaged-update-handoff-recycled.cjs index 65f08435c..86b6f9552 100644 --- a/scripts/smoke-packaged-update-handoff-recycled.cjs +++ b/scripts/smoke-packaged-update-handoff-recycled.cjs @@ -48,7 +48,10 @@ async function runRecycledPidScenario(input, deps) { const helper = spawnTracked(process.execPath, [ join(__dirname, 'fixtures', 'update-handoff-owner.cjs'), - '--data-dir', profile.dataDir, + // This is an unrelated process that deliberately reuses the old PID. + // Do not advertise the Runtime's real --data-dir flag in its command; + // legacy ownership inventory correctly treats that flag as an owner. + '--fixture-data-dir', profile.dataDir, '--scenario', 'pid-port-reuse', '--build-id', predecessor.buildId ], { cwd: profile.workspaceRoot, env: profile.environment }) @@ -109,7 +112,10 @@ async function runRecycledPidScenario(input, deps) { cleanupErrors = await deps.cleanupTracked(tracked) await deps.cleanupProfile(root).catch((error) => cleanupErrors.push(error.message ?? String(error))) } - if (primaryError) throw primaryError + if (primaryError) { + const detail = tracked.map((entry) => entry.output?.() ?? '').filter(Boolean).join('\n') + throw new Error(`${primaryError.stack ?? primaryError}${detail ? `\nProcess output:\n${detail}` : ''}`) + } if (cleanupErrors.length > 0) { throw new Error(`Recycled PID handoff cleanup failed: ${cleanupErrors.join('; ')}`) } diff --git a/scripts/smoke-packaged-update-handoff.cjs b/scripts/smoke-packaged-update-handoff.cjs index cd83d01e2..9b2d0f8d4 100644 --- a/scripts/smoke-packaged-update-handoff.cjs +++ b/scripts/smoke-packaged-update-handoff.cjs @@ -83,8 +83,11 @@ async function main() { const candidate = await readPackagedBuild(resourcesDir) const selection = argumentValue('--cases') ?? 'all' const runPositive = selection === 'all' || selection === 'positive' + const runRecycled = runPositive || selection === 'recycled' const runNegative = selection === 'all' || selection === 'negative' - if (!runPositive && !runNegative) throw new Error('--cases must be all, positive, or negative') + if (!runPositive && !runRecycled && !runNegative) { + throw new Error('--cases must be all, positive, recycled, or negative') + } for (const scenario of runPositive ? POSITIVE_SCENARIOS : []) { await runPositiveScenario({ @@ -97,7 +100,7 @@ async function main() { timeoutMs }) } - for (const scenario of runPositive ? RECYCLED_PID_SCENARIOS : []) { + for (const scenario of runRecycled ? RECYCLED_PID_SCENARIOS : []) { await runRecycledPidScenario({ scenario, resourcesDir, @@ -128,7 +131,7 @@ async function main() { } process.stdout.write( `Packaged update handoff smoke OK (${process.platform}/${process.arch}): ` + - `${runPositive ? POSITIVE_SCENARIOS.length + RECYCLED_PID_SCENARIOS.length : 0} update paths and ` + + `${(runPositive ? POSITIVE_SCENARIOS.length : 0) + (runRecycled ? RECYCLED_PID_SCENARIOS.length : 0)} update paths and ` + `${runNegative ? NEGATIVE_SCENARIOS.length : 0} fail-closed owner cases passed.\n` ) } diff --git a/scripts/smoke-packaged-update-handoff.test.cjs b/scripts/smoke-packaged-update-handoff.test.cjs index f8de97fec..fe95862f7 100644 --- a/scripts/smoke-packaged-update-handoff.test.cjs +++ b/scripts/smoke-packaged-update-handoff.test.cjs @@ -47,6 +47,15 @@ test('recycled PID release matrix proves exact stale coordination cleanup', () = ]) }) +test('recycled PID helper does not advertise itself as a Runtime data-dir owner', () => { + const source = readFileSync( + join(process.cwd(), 'scripts/smoke-packaged-update-handoff-recycled.cjs'), + 'utf8' + ) + assert.match(source, /'--fixture-data-dir', profile\.dataDir/u) + assert.doesNotMatch(source, /'--data-dir', profile\.dataDir/u) +}) + test('synthetic predecessor and development flavor use distinct stable build IDs', () => { const candidate = 'b'.repeat(64) const predecessor = predecessorBuildId(candidate) diff --git a/scripts/smoke-windows-installer-migration.ps1 b/scripts/smoke-windows-installer-migration.ps1 index 8a76d3d7f..7ac28278a 100644 --- a/scripts/smoke-windows-installer-migration.ps1 +++ b/scripts/smoke-windows-installer-migration.ps1 @@ -621,6 +621,26 @@ try { })) { Copy-Item -LiteralPath $evidence.FullName -Destination $artifactRoot -Force } + $appLogDirectory = Join-Path $env:APPDATA 'Kun\logs' + if (Test-Path -LiteralPath $appLogDirectory -PathType Container) { + Copy-Item -LiteralPath $appLogDirectory ` + -Destination (Join-Path $artifactRoot 'app-logs') -Recurse -Force + } + foreach ($healthArtifact in @(Get-ChildItem -LiteralPath $env:TEMP ` + -Filter 'Kun-update-health-*' -File -ErrorAction SilentlyContinue)) { + Copy-Item -LiteralPath $healthArtifact.FullName -Destination $artifactRoot -Force + } + @(Get-CimInstance Win32_Process -Filter "Name = 'Kun.exe'" -ErrorAction SilentlyContinue | ForEach-Object { + $redactedCommandLine = ([string]$_.CommandLine ` + -replace '--kun-update-health-token=(?:"[^"]*"|\S+)', '--kun-update-health-token=') ` + -replace '--runtime-token(?:=|\s+)(?:"[^"]*"|\S+)', '--runtime-token=' + [ordered]@{ + processId = $_.ProcessId + parentProcessId = $_.ParentProcessId + commandLine = $redactedCommandLine + } + }) | ConvertTo-Json | Set-Content ` + -LiteralPath (Join-Path $artifactRoot 'kun-processes.json') -Encoding UTF8 [ordered]@{ scenario = $currentScenario message = $failureRecord.Exception.Message diff --git a/scripts/verify-linux-package-architecture.mjs b/scripts/verify-linux-package-architecture.mjs index 2a436b2d4..a95c82b04 100644 --- a/scripts/verify-linux-package-architecture.mjs +++ b/scripts/verify-linux-package-architecture.mjs @@ -89,10 +89,6 @@ function targetNativeModuleGroups(arch) { label: 'sharp', pattern: new RegExp(`/node_modules/@img/sharp-linux-${arch}/lib/[^/]+\\.node$`, 'u') }, - { - label: 'keytar', - pattern: new RegExp(`/node_modules/@github/keytar/prebuilds/linux-${arch}/keytar\\.node$`, 'u') - }, ...(arch === 'x64' ? [{ label: '@computer-use/libnut-linux', diff --git a/scripts/verify-linux-package-architecture.test.mjs b/scripts/verify-linux-package-architecture.test.mjs index aa0864f46..900fb94c4 100644 --- a/scripts/verify-linux-package-architecture.test.mjs +++ b/scripts/verify-linux-package-architecture.test.mjs @@ -13,7 +13,6 @@ function targetModules(arch) { '/app/node_modules/node-pty/build/Release/pty.node', `/app/node_modules/@napi-rs/canvas-linux-${arch}-gnu/skia.linux-${arch}-gnu.node`, `/app/node_modules/@img/sharp-linux-${arch}/lib/sharp-linux-${arch}.node`, - `/app/kun/node_modules/@github/keytar/prebuilds/linux-${arch}/keytar.node`, ...(arch === 'x64' ? ['/app/node_modules/@computer-use/libnut-linux/build/Release/libnut.node'] : []) diff --git a/src/main/agent-sdk-installer-network.ts b/src/main/agent-sdk-installer-network.ts index e32188502..68efa57aa 100644 --- a/src/main/agent-sdk-installer-network.ts +++ b/src/main/agent-sdk-installer-network.ts @@ -247,7 +247,7 @@ export async function extractExactBinary(archive: string, memberName: string, de if (!found || extractedSize <= 0) throw new Error('binary not found in tarball') const info = await stat(destination) if (!info.isFile() || info.size !== extractedSize) throw new Error('extracted binary size does not match archive header') - const handle = await open(destination, 'r') + const handle = await open(destination, 'r+') try { await handle.sync() } finally { await handle.close() } return extractedSize } diff --git a/src/main/app-icon.ts b/src/main/app-icon.ts index b8b81d882..96abc6e8a 100644 --- a/src/main/app-icon.ts +++ b/src/main/app-icon.ts @@ -1,9 +1,9 @@ import { readFileSync } from 'node:fs' -import { dirname, isAbsolute, relative, resolve, sep, win32 } from 'node:path' -import { fileURLToPath } from 'node:url' +import { isAbsolute, relative, resolve, sep, win32 } from 'node:path' import { nativeImage } from 'electron' +import { mainBundleDirectory } from './main-bundle-path' -const __dirname = dirname(fileURLToPath(import.meta.url)) +const __dirname = mainBundleDirectory(import.meta.url) function usesWin32PathRules(baseDir: string): boolean { return (win32.isAbsolute(baseDir) && !baseDir.startsWith('/')) || diff --git a/src/main/atomic-json-file.test.ts b/src/main/atomic-json-file.test.ts index 11d78720d..1ad486eff 100644 --- a/src/main/atomic-json-file.test.ts +++ b/src/main/atomic-json-file.test.ts @@ -12,7 +12,9 @@ describe('atomicWriteFile', () => { await atomicWriteFile(target, '{"next":true}\n') expect(await readFile(target, 'utf8')).toBe('{"next":true}\n') - expect((await stat(target)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') { + expect((await stat(target)).mode & 0o777).toBe(0o600) + } expect((await readdir(directory)).filter((name) => name.endsWith('.tmp'))).toEqual([]) } finally { await rm(directory, { recursive: true, force: true }) diff --git a/src/main/bundled-skill-resources.ts b/src/main/bundled-skill-resources.ts index 795e12bfa..2d79741a2 100644 --- a/src/main/bundled-skill-resources.ts +++ b/src/main/bundled-skill-resources.ts @@ -1,5 +1,5 @@ import { app } from 'electron' -import { join, resolve } from 'node:path' +import { posix, win32 } from 'node:path' export function bundledSkillsDirectory(options?: { isPackaged?: boolean @@ -8,7 +8,13 @@ export function bundledSkillsDirectory(options?: { }): string { const isPackaged = options?.isPackaged ?? app.isPackaged if (isPackaged) { - return join(options?.resourcesPath ?? process.resourcesPath, 'bundled-skills') + const root = options?.resourcesPath ?? process.resourcesPath + return pathApiFor(root).join(root, 'bundled-skills') } - return resolve(options?.appRoot ?? app.getAppPath(), 'resources', 'bundled-skills') + const root = options?.appRoot ?? app.getAppPath() + return pathApiFor(root).resolve(root, 'resources', 'bundled-skills') +} + +function pathApiFor(root: string): typeof posix | typeof win32 { + return /^[A-Za-z]:[\\/]/u.test(root) || root.startsWith('\\\\') ? win32 : posix } diff --git a/src/main/claw-runtime.thread-model-routing.test.ts b/src/main/claw-runtime.thread-model-routing.test.ts index c50ef0941..add0f1a39 100644 --- a/src/main/claw-runtime.thread-model-routing.test.ts +++ b/src/main/claw-runtime.thread-model-routing.test.ts @@ -437,9 +437,10 @@ describe('ClawRuntime', () => { { markdown?: string }, Record ] - expect(modelListCall[1]).toMatchObject({ markdown: expect.stringContaining('3. `MiniMax-M3` · provider `minimax-a`') }) - expect(modelListCall[1]).toMatchObject({ markdown: expect.stringContaining('5. `MiniMax-M3` · provider `minimax`') }) - expect(modelListCall[1]).toMatchObject({ markdown: expect.stringContaining('provider `minimax`') }) + const modelList = modelListCall[1].markdown ?? '' + expect(modelList).toContain('`MiniMax-M3` · provider `minimax-a`') + const minimaxEntry = modelList.match(/(\d+)\.\s+`MiniMax-M3`[^\n]*provider `minimax`/) + expect(minimaxEntry).not.toBeNull() await handleFeishuMessage('/model MiniMax-M3', 'om_model_name_switch') expect(current().claw.channels[0]).toMatchObject({ @@ -452,7 +453,7 @@ describe('ClawRuntime', () => { { replyTo: 'om_model_name_switch', replyInThread: false } ) - await handleFeishuMessage('/model 5', 'om_model_switch') + await handleFeishuMessage(`/model ${minimaxEntry![1]}`, 'om_model_switch') expect(current().claw.channels[0]).toMatchObject({ providerId: 'minimax', model: 'MiniMax-M2.7' diff --git a/src/main/gui-updater.test.ts b/src/main/gui-updater.test.ts index 4685bcba8..a3ccf1a13 100644 --- a/src/main/gui-updater.test.ts +++ b/src/main/gui-updater.test.ts @@ -57,6 +57,13 @@ beforeEach(() => { appListeners = new Map() vi.doMock('node:fs/promises', () => ({ mkdir: vi.fn().mockResolvedValue(undefined), + open: vi.fn(async (path: string) => ({ + writeFile: vi.fn(async (value: string) => { + mockedFiles.set(String(path), String(value)) + }), + sync: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined) + })), readFile: vi.fn(async (path: string) => { const value = mockedFiles.get(String(path)) if (value === undefined) throw Object.assign(new Error('not found'), { code: 'ENOENT' }) diff --git a/src/main/index.ts b/src/main/index.ts index 56c0fc030..6e562df0a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,98 +1,93 @@ import { app } from 'electron' import { - mainState, - runningClawScheduleMcpServer -} from './main-app-context' -import { - runClawScheduleMcpServerFromArgv -} from './claw-schedule-mcp-server' -import { - releaseRuntimeDataRecoveryMigrationLock -} from './main-migrations' -import { - runtimeShutdown, - stopCheckpointCleanupTimer, - stopManagedRuntimes, - stopManagedRuntimesForQuit -} from './main-lifecycle' -import { stopRuntimeWatchdog } from './main-runtime-health' -import { requestProviderMutationFlush } from './provider-mutation-barrier' -import { startMainApp } from './main-ready' -import { readUpdateHealthRequest, runUpdateHealthCheck } from './update-health-check' -import { - packagedUpdateHandoffSmokeFailure, - packagedUpdateHandoffSmokeRequested, - runPackagedUpdateHandoffSmoke -} from './packaged-update-handoff-smoke' + appendUpdateHealthProgress, + UPDATE_HEALTH_INTERNAL_TIMEOUT_MS, + updateHealthDiagnosticBasePath, + writeUpdateHealthBootstrapFailure +} from './update-health-bootstrap' -if (runningClawScheduleMcpServer) { - void runClawScheduleMcpServerFromArgv(process.argv).catch((error) => { - console.error('[claw-schedule-mcp] server failed:', error) - process.exit(1) - }) -} else if (packagedUpdateHandoffSmokeRequested()) { - void runPackagedUpdateHandoffSmoke().then( - () => app.exit(0), - (error) => { - process.stderr.write(`${packagedUpdateHandoffSmokeFailure(error)}\n`) - app.exit(70) +const HEALTH_PATH_ARG = '--kun-update-health-check=' +const HEALTH_TOKEN_ARG = '--kun-update-health-token=' +const HEALTH_TARGET_ARG = '--kun-update-target=' + +function bootstrapArgumentValue(prefix: string): string { + for (const argument of process.argv) { + const offset = argument.indexOf(prefix) + if (offset < 0) continue + const remainder = argument.slice(offset + prefix.length).trimStart() + if (!remainder) return '' + const quote = remainder[0] === '"' || remainder[0] === "'" ? remainder[0] : '' + if (quote) { + const end = remainder.indexOf(quote, 1) + return (end < 0 ? remainder.slice(1) : remainder.slice(1, end)).trim() } - ) -} else { - const updateHealthRequest = readUpdateHealthRequest() - if (updateHealthRequest) { - mainState.updateHealthProbeOnly = true - void runUpdateHealthCheck(updateHealthRequest).then( - () => app.exit(0), - (error) => { - console.error('[kun-gui update health] failed:', error) - app.exit(71) - } - ) - } else { - void startMainApp() + const nextFlag = remainder.search(/\s+--[A-Za-z0-9-]+=/u) + return (nextFlag < 0 ? remainder : remainder.slice(0, nextFlag)).trim() } + return '' } -app.on('window-all-closed', () => { - if (process.platform === 'darwin') return - void stopManagedRuntimes().catch((error) => { - console.warn('[kun-gui] failed to stop Kun runtime:', error) - }) - app.quit() -}) - -let quitBarrierPromise: Promise | null = null -let quitBarrierCompleted = false +const bootstrapHealthRequest = { + resultPath: bootstrapArgumentValue(HEALTH_PATH_ARG), + token: bootstrapArgumentValue(HEALTH_TOKEN_ARG), + target: bootstrapArgumentValue(HEALTH_TARGET_ARG) +} -app.on('before-quit', (event) => { - if (quitBarrierCompleted) return - event.preventDefault() - if (quitBarrierPromise) return - quitBarrierPromise = (async () => { +if (bootstrapHealthRequest.resultPath) { + const healthStartedAt = new Date().toISOString() + const healthDeadlineAt = Date.now() + UPDATE_HEALTH_INTERNAL_TIMEOUT_MS + const reportProgress = ( + phase: string, + detail?: Record + ): void => { try { - releaseRuntimeDataRecoveryMigrationLock() - } catch (error) { - console.error('[kun-gui] failed to release Runtime data recovery lock during quit:', error) - } - runtimeShutdown.requestQuit() - mainState.protectedCredentialSurface?.dispose() - const mutationFlush = await requestProviderMutationFlush(() => mainState.mainWindow) - if (!mutationFlush.ok) { - console.warn('[kun-gui] provider mutation flush did not complete before quit:', { - errorCode: mutationFlush.errorCode, - pendingProviderIds: mutationFlush.pendingProviderIds, - mutationKinds: mutationFlush.mutationKinds + appendUpdateHealthProgress({ + resultPath: bootstrapHealthRequest.resultPath, + target: bootstrapHealthRequest.target, + startedAt: healthStartedAt, + phase, + detail }) + } catch (error) { + console.error('[kun-gui update health] failed to record progress:', error) } - stopRuntimeWatchdog() - stopCheckpointCleanupTimer() - if (!runtimeShutdown.isStoppedForQuit) { - await stopManagedRuntimesForQuit().catch((error) => { - console.warn('[kun-gui] failed to stop Kun runtime:', error) + } + reportProgress('bootstrap') + void import('./update-health-check') + .then(async ({ readUpdateHealthRequest, runUpdateHealthCheck }) => { + const request = readUpdateHealthRequest() + if (!request) throw new Error('The update health request disappeared during startup.') + await runUpdateHealthCheck(request, { + deadlineAt: healthDeadlineAt, + diagnosticBasePath: updateHealthDiagnosticBasePath(request.resultPath), + reportProgress }) + }) + .then( + () => app.exit(0), + (error) => { + try { + writeUpdateHealthBootstrapFailure({ + error, + executablePath: process.execPath, + ...bootstrapHealthRequest, + argv: process.argv, + startedAt: healthStartedAt, + version: app.getVersion() + }) + } catch (writeError) { + console.error('[kun-gui update health] failed to record bootstrap failure:', writeError) + } + console.error('[kun-gui update health] failed:', error) + app.exit(71) + } + ) +} else { + void import('./main-desktop-entry').then( + ({ startDesktopMainEntry }) => startDesktopMainEntry(), + (error) => { + console.error('[kun-gui] desktop entry failed to load:', error) + app.exit(1) } - quitBarrierCompleted = true - app.quit() - })() -}) + ) +} diff --git a/src/main/ipc/app-ipc-schemas/settings-model.ts b/src/main/ipc/app-ipc-schemas/settings-model.ts index 0194bbacc..66f6490ad 100644 --- a/src/main/ipc/app-ipc-schemas/settings-model.ts +++ b/src/main/ipc/app-ipc-schemas/settings-model.ts @@ -337,6 +337,20 @@ export const kunRuntimePatchSchema = z.object({ topKMax: z.number().int().positive().optional(), minScore: z.number().nonnegative().optional() }).strict().optional(), + githubMcp: z.object({ + enabled: z.boolean().optional(), + githubHost: trimmedString(253).optional(), + allowedHosts: z.array(trimmedString(253)).max(64).optional(), + allowedOrganizations: z.array(trimmedString(64)).max(128).optional(), + allowedRepositories: z.array(trimmedString(256)).max(256).optional(), + authorization: z.object({ + source: z.enum(['GITHUB_PAT_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN', 'github-cli']), + host: trimmedString(253), + login: trimmedString(256), + scopes: z.array(trimmedString(256)).max(256), + fingerprint: z.string().trim().regex(/^[0-9a-fA-F]{64}$/) + }).strict().nullable().optional() + }).strict().optional(), projectConfig: z.object({ grants: z.array(z.object({ workspaceRoot: trimmedString(MAX_PATH_LENGTH), diff --git a/src/main/ipc/register-app-ipc-handlers.project-runtime.test.ts b/src/main/ipc/register-app-ipc-handlers.project-runtime.test.ts index a8b07a968..784033503 100644 --- a/src/main/ipc/register-app-ipc-handlers.project-runtime.test.ts +++ b/src/main/ipc/register-app-ipc-handlers.project-runtime.test.ts @@ -35,6 +35,10 @@ import { registerAppIpcHandlers } from './register-app-ipc-handlers' +vi.mock('../main-window', () => ({ + trustedWorkbenchRendererUrl: () => 'http://127.0.0.1:5173/index.html' +})) + const electronMock = getAppIpcElectronMock() describe('registerAppIpcHandlers project config and runtime routing', () => { diff --git a/src/main/ipc/register-app-ipc-handlers.test-support.ts b/src/main/ipc/register-app-ipc-handlers.test-support.ts index dbfd2420f..0b065f050 100644 --- a/src/main/ipc/register-app-ipc-handlers.test-support.ts +++ b/src/main/ipc/register-app-ipc-handlers.test-support.ts @@ -77,6 +77,9 @@ const telegramMocks = vi.hoisted(() => ({ })) vi.mock('electron', () => ({ + protocol: { + registerSchemesAsPrivileged: vi.fn() + }, app: { quit: vi.fn(), getPath: vi.fn(() => electronMock.userDataPath), diff --git a/src/main/ipc/register-app-ipc-handlers.ui-runtime.test.ts b/src/main/ipc/register-app-ipc-handlers.ui-runtime.test.ts index 44216daa2..3054bf3f0 100644 --- a/src/main/ipc/register-app-ipc-handlers.ui-runtime.test.ts +++ b/src/main/ipc/register-app-ipc-handlers.ui-runtime.test.ts @@ -26,11 +26,28 @@ import { tmpdir } from 'node:os' import { + createHash +} from 'node:crypto' +import { + dirname, join } from 'node:path' import { registerAppIpcHandlers } from './register-app-ipc-handlers' +import { + AGENT_SDK_INTEGRITY_BY_PACKAGE, + AGENT_SDK_VERSION, + claudeBinaryName, + platformBinaryPackage +} from '../agent-sdk-installer' +import { + agentSdkRoot, + manifestRelativePath, + serializeActivePointer, + serializeManifest, + type AgentSdkInstallManifest +} from '../agent-sdk-installer-storage' vi.mock('../main-window', () => ({ trustedWorkbenchRendererUrl: () => 'http://127.0.0.1:5173/index.html' @@ -462,12 +479,33 @@ describe('registerAppIpcHandlers UI plugins and runtime', () => { it('restarts Kun after an already-downloaded Claude SDK is provisioned through IPC', async () => { const userDataDir = mkdtempSync(join(tmpdir(), 'kun-agent-sdk-ipc-')) - const binaryName = process.platform === 'win32' ? 'claude.exe' : 'claude' - const binaryPath = join(userDataDir, 'agent-sdk', binaryName) + const binaryName = claudeBinaryName() + const packageName = platformBinaryPackage()! + const binary = Buffer.from('authenticated claude binary') + const manifest: AgentSdkInstallManifest = { + schemaVersion: 1, + sdkVersion: AGENT_SDK_VERSION, + packageName, + platform: process.platform, + arch: process.arch, + binaryName, + binarySize: binary.length, + binarySha256: createHash('sha256').update(binary).digest('hex'), + cliVersion: 'test Claude Code', + helpProbe: 'Usage: claude [options]', + integrity: AGENT_SDK_INTEGRITY_BY_PACKAGE[packageName]!, + installedAt: new Date().toISOString() + } + const sdkRoot = agentSdkRoot(userDataDir) + const manifestPath = join(sdkRoot, manifestRelativePath(manifest)) + const manifestBytes = serializeManifest(manifest) + const binaryPath = join(dirname(manifestPath), binaryName) const restartKunServe = vi.fn(async () => undefined) electronMock.userDataPath = userDataDir - mkdirSync(join(userDataDir, 'agent-sdk'), { recursive: true }) - writeFileSync(binaryPath, 'claude binary') + mkdirSync(dirname(manifestPath), { recursive: true }) + writeFileSync(binaryPath, binary) + writeFileSync(manifestPath, manifestBytes) + writeFileSync(join(sdkRoot, 'active.json'), serializeActivePointer(manifest, manifestBytes)) try { registerAppIpcHandlers(registerOptions({ restartKunServe })) diff --git a/src/main/ipc/register-app-ipc-handlers.workspace.test.ts b/src/main/ipc/register-app-ipc-handlers.workspace.test.ts index 6c44bdfc9..e191c85bf 100644 --- a/src/main/ipc/register-app-ipc-handlers.workspace.test.ts +++ b/src/main/ipc/register-app-ipc-handlers.workspace.test.ts @@ -231,7 +231,7 @@ describe('registerAppIpcHandlers workspace and MCP', () => { const temp = mkdtempSync(join(tmpdir(), 'kun-watch-signal-')) const target = join(temp, 'report.docx') writeFileSync(target, Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0xff])) - const resolvedTarget = realpathSync(target) + const resolvedTarget = realpathSync.native(target) const sender = Object.assign(new EventEmitter(), { id: 74, send: vi.fn(), @@ -297,7 +297,7 @@ describe('registerAppIpcHandlers workspace and MCP', () => { const temp = mkdtempSync(join(tmpdir(), 'kun-office-preview-')) const target = join(temp, 'report.docx') writeFileSync(target, 'office-preview-source') - const resolvedTarget = realpathSync(target) + const resolvedTarget = realpathSync.native(target) const mainFrame = { processId: 10, routingId: 20, url: 'http://127.0.0.1:5173/index.html' } const sender = Object.assign(new EventEmitter(), { id: 76, @@ -369,7 +369,7 @@ describe('registerAppIpcHandlers workspace and MCP', () => { const temp = mkdtempSync(join(tmpdir(), 'kun-office-semantic-')) const target = join(temp, 'report.docx') writeFileSync(target, 'office-semantic-source') - const resolvedTarget = realpathSync(target) + const resolvedTarget = realpathSync.native(target) const mainFrame = { processId: 10, routingId: 20, url: 'http://127.0.0.1:5173/index.html' } const sender = Object.assign(new EventEmitter(), { id: 77, @@ -435,7 +435,7 @@ describe('registerAppIpcHandlers workspace and MCP', () => { officeCliResourceMocks.resolveOfficeCliBinary.mockReturnValue('/tmp/officecli') spreadsheetServiceMocks.saveWorkspaceSpreadsheet.mockResolvedValue({ ok: true, - path: realpathSync(xlsxPath), + path: realpathSync.native(xlsxPath), sourceSha256: 'b'.repeat(64), size: 12, mtimeMs: 2, @@ -465,7 +465,7 @@ describe('registerAppIpcHandlers workspace and MCP', () => { } await expect(saveHandler({ sender, senderFrame: mainFrame }, savePayload)).resolves.toMatchObject({ ok: true }) expect(spreadsheetServiceMocks.saveWorkspaceSpreadsheet).toHaveBeenCalledWith({ - path: realpathSync(xlsxPath), + path: realpathSync.native(xlsxPath), expectedSha256, mutations: savePayload.mutations }, expect.objectContaining({ binaryPath: '/tmp/officecli', signal: expect.any(AbortSignal) })) @@ -475,7 +475,7 @@ describe('registerAppIpcHandlers workspace and MCP', () => { path: 'legacy.xls', workspaceRoot: temp, expectedSha256 })).resolves.toMatchObject({ ok: true, name: 'legacy.xlsx' }) expect(spreadsheetServiceMocks.convertWorkspaceSpreadsheet).toHaveBeenCalledWith({ - path: realpathSync(xlsPath), expectedSha256 + path: realpathSync.native(xlsPath), expectedSha256 }, expect.objectContaining({ signal: expect.any(AbortSignal) })) await expect(saveHandler({ sender, senderFrame: mainFrame }, { diff --git a/src/main/ipc/register-extension-ipc-handlers.test-support.ts b/src/main/ipc/register-extension-ipc-handlers.test-support.ts index 8f6f5e9ee..5aeb61a8f 100644 --- a/src/main/ipc/register-extension-ipc-handlers.test-support.ts +++ b/src/main/ipc/register-extension-ipc-handlers.test-support.ts @@ -23,6 +23,9 @@ const electronMock = vi.hoisted(() => ({ })) vi.mock('electron', () => ({ + protocol: { + registerSchemesAsPrivileged: vi.fn() + }, dialog: { showOpenDialog: electronMock.showOpenDialog, showSaveDialog: electronMock.showSaveDialog, diff --git a/src/main/kun-process.test.ts b/src/main/kun-process.test.ts index 2e54324e0..3f51e5791 100644 --- a/src/main/kun-process.test.ts +++ b/src/main/kun-process.test.ts @@ -217,7 +217,18 @@ describe('Manager-owned Main data plane', () => { return Response.json({ snapshot: { revision: 2, value: null } }) } if (url.endsWith('/release')) return Response.json({ released: true }) - return Response.json({ acquired: true }) + const now = Date.now() + return Response.json({ + acquired: true, + lease: { + resource: 'main-registry', + ownerFlavor: 'production', + ownerInstanceId: 'main-one', + fencingToken: 1, + acquiredAt: new Date(now).toISOString(), + expiresAt: new Date(now + 10_000).toISOString() + } + }) })) await expect(existingClient.read()).resolves.toEqual({ revision: 2, value: null }) diff --git a/src/main/main-app-context.ts b/src/main/main-app-context.ts index d2f81ead7..f8a8bcd8e 100644 --- a/src/main/main-app-context.ts +++ b/src/main/main-app-context.ts @@ -9,12 +9,8 @@ import { homedir } from 'node:os' import { - dirname, join } from 'node:path' -import { - fileURLToPath -} from 'node:url' import { JsonSettingsStore, devServerHintUrl @@ -132,8 +128,9 @@ import { isTrustedRendererUrl, normalizeRendererPathname } from './renderer-trust-policy' +import { mainBundleDirectory } from './main-bundle-path' -export const __dirname = dirname(fileURLToPath(import.meta.url)) +export const __dirname = mainBundleDirectory(import.meta.url) /** Compare only the immutable renderer origin and entry document; query/hash are UI state. */ export function isTrustedWorkbenchUrl(candidate: string, trustedRendererUrl: string): boolean { diff --git a/src/main/main-bundle-path.test.ts b/src/main/main-bundle-path.test.ts new file mode 100644 index 000000000..04befcb73 --- /dev/null +++ b/src/main/main-bundle-path.test.ts @@ -0,0 +1,15 @@ +import { pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' +import { mainBundleDirectory } from './main-bundle-path' + +describe('main bundle path', () => { + it('keeps the entry bundle rooted at out/main', () => { + expect(mainBundleDirectory(pathToFileURL('/app/out/main/index.js').href)) + .toBe('/app/out/main') + }) + + it('normalizes a split chunk back to out/main', () => { + expect(mainBundleDirectory(pathToFileURL('/app/out/main/chunks/desktop.js').href)) + .toBe('/app/out/main') + }) +}) diff --git a/src/main/main-bundle-path.ts b/src/main/main-bundle-path.ts new file mode 100644 index 000000000..7cf30d1b9 --- /dev/null +++ b/src/main/main-bundle-path.ts @@ -0,0 +1,8 @@ +import { basename, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** Resolve the stable out/main root from either the entry or a split chunk. */ +export function mainBundleDirectory(moduleUrl: string): string { + const directory = dirname(fileURLToPath(moduleUrl)) + return basename(directory) === 'chunks' ? dirname(directory) : directory +} diff --git a/src/main/main-desktop-entry.ts b/src/main/main-desktop-entry.ts new file mode 100644 index 000000000..29d74b2a8 --- /dev/null +++ b/src/main/main-desktop-entry.ts @@ -0,0 +1,83 @@ +import { app } from 'electron' +import { + mainState, + runningClawScheduleMcpServer +} from './main-app-context' +import { runClawScheduleMcpServerFromArgv } from './claw-schedule-mcp-server' +import { releaseRuntimeDataRecoveryMigrationLock } from './main-migrations' +import { + runtimeShutdown, + stopCheckpointCleanupTimer, + stopManagedRuntimes, + stopManagedRuntimesForQuit +} from './main-lifecycle' +import { stopRuntimeWatchdog } from './main-runtime-health' +import { requestProviderMutationFlush } from './provider-mutation-barrier' +import { startMainApp } from './main-ready' +import { + packagedUpdateHandoffSmokeFailure, + packagedUpdateHandoffSmokeRequested, + runPackagedUpdateHandoffSmoke +} from './packaged-update-handoff-smoke' + +export function startDesktopMainEntry(): void { + if (runningClawScheduleMcpServer) { + void runClawScheduleMcpServerFromArgv(process.argv).catch((error) => { + console.error('[claw-schedule-mcp] server failed:', error) + process.exit(1) + }) + } else if (packagedUpdateHandoffSmokeRequested()) { + void runPackagedUpdateHandoffSmoke().then( + () => app.exit(0), + (error) => { + process.stderr.write(`${packagedUpdateHandoffSmokeFailure(error)}\n`) + app.exit(70) + } + ) + } else { + void startMainApp() + } + + app.on('window-all-closed', () => { + if (process.platform === 'darwin') return + void stopManagedRuntimes().catch((error) => { + console.warn('[kun-gui] failed to stop Kun runtime:', error) + }) + app.quit() + }) + + let quitBarrierPromise: Promise | null = null + let quitBarrierCompleted = false + + app.on('before-quit', (event) => { + if (quitBarrierCompleted) return + event.preventDefault() + if (quitBarrierPromise) return + quitBarrierPromise = (async () => { + try { + releaseRuntimeDataRecoveryMigrationLock() + } catch (error) { + console.error('[kun-gui] failed to release Runtime data recovery lock during quit:', error) + } + runtimeShutdown.requestQuit() + mainState.protectedCredentialSurface?.dispose() + const mutationFlush = await requestProviderMutationFlush(() => mainState.mainWindow) + if (!mutationFlush.ok) { + console.warn('[kun-gui] provider mutation flush did not complete before quit:', { + errorCode: mutationFlush.errorCode, + pendingProviderIds: mutationFlush.pendingProviderIds, + mutationKinds: mutationFlush.mutationKinds + }) + } + stopRuntimeWatchdog() + stopCheckpointCleanupTimer() + if (!runtimeShutdown.isStoppedForQuit) { + await stopManagedRuntimesForQuit().catch((error) => { + console.warn('[kun-gui] failed to stop Kun runtime:', error) + }) + } + quitBarrierCompleted = true + app.quit() + })() + }) +} diff --git a/src/main/main-entry-routing.test.ts b/src/main/main-entry-routing.test.ts new file mode 100644 index 000000000..fda721e89 --- /dev/null +++ b/src/main/main-entry-routing.test.ts @@ -0,0 +1,15 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +describe('main entry routing', () => { + it('keeps update health ahead of the full desktop module graph', () => { + const source = readFileSync(new URL('./index.ts', import.meta.url), 'utf8') + + expect(source).toContain("import('./main-desktop-entry')") + expect(source).toContain("import('./update-health-check')") + expect(source).not.toContain("from './main-app-context'") + expect(source.indexOf('bootstrapHealthRequest.resultPath')).toBeLessThan( + source.indexOf("import('./main-desktop-entry')") + ) + }) +}) diff --git a/src/main/packaging-config.hooks.test.ts b/src/main/packaging-config.hooks.test.ts index 39fc22555..2679063d8 100644 --- a/src/main/packaging-config.hooks.test.ts +++ b/src/main/packaging-config.hooks.test.ts @@ -439,6 +439,7 @@ it('passes the nested OfficeCLI executable through the Windows signing manager', expect(installerScript).toContain('windows-installer-migration-journal.ps1') expect(installerScript).toContain('windows-installer-migration-filesystem.ps1') expect(installerScript).toContain('windows-installer-migration-actions.ps1') + expect(installerScript).toContain('windows-installer-migration-recovery-env.ps1') expect(installerScript).toContain('!ifdef BUILD_UNINSTALLER') expect(installerScript).toContain('${ifNot} ${isUpdated}') expect(installerScript).toContain('MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)"') diff --git a/src/main/runtime-data-dir-preserving-migration.test.ts b/src/main/runtime-data-dir-preserving-migration.test.ts index fc29f07f4..a72c8f1ec 100644 --- a/src/main/runtime-data-dir-preserving-migration.test.ts +++ b/src/main/runtime-data-dir-preserving-migration.test.ts @@ -607,8 +607,8 @@ describe('history-preserving Kun Runtime migration', () => { const sourceLicense = join(sourcePackage, 'LICENSE') const targetLicense = join(targetPackage, 'LICENSE') await writeFile(sourceLicense, 'immutable package', 'utf8') - await chmod(sourceLicense, 0o444) - await chmod(sourcePackage, 0o555) + if (process.platform !== 'win32') await chmod(sourceLicense, 0o444) + if (process.platform !== 'win32') await chmod(sourcePackage, 0o555) const result = runCanonicalKunRuntimeDataMigration({ userDataPath: test.userData, @@ -624,8 +624,8 @@ describe('history-preserving Kun Runtime migration', () => { expect((await stat(sourceLicense)).mode & 0o777).toBe(0o444) expect((await stat(targetLicense)).mode & 0o777).toBe(0o444) } - await chmod(sourcePackage, 0o755) - await chmod(targetPackage, 0o755) + if (process.platform !== 'win32') await chmod(sourcePackage, 0o755) + if (process.platform !== 'win32') await chmod(targetPackage, 0o755) }) it('rebases only the candidate extension registry', async () => { diff --git a/src/main/runtime/kun-adapter.test.ts b/src/main/runtime/kun-adapter.test.ts index f2e3a661a..ee58a1192 100644 --- a/src/main/runtime/kun-adapter.test.ts +++ b/src/main/runtime/kun-adapter.test.ts @@ -31,6 +31,7 @@ import { import { buildRuntimeCapabilityManifest } from '../../../kun/src/contracts/capabilities.js' import { modelCapabilitiesForModel } from '../../../kun/src/loop/model-context-profile.js' import { publishRuntimeDiscovery } from '../../../kun/src/server/runtime-discovery.js' +import { KUN_VERSION } from '../../../kun/src/version.js' let server: Server | null = null @@ -489,7 +490,7 @@ describe('kunRuntimeAdapter.resolveConnection', () => { res.setHeader('x-kun-active-turn-count', String(activeTurnCount)) res.end(JSON.stringify({ instanceId, - serviceVersion: '0.1.0', + serviceVersion: KUN_VERSION, ...(liveBuildId ? { buildId: liveBuildId } : {}), launchMode: 'shared', host: '127.0.0.1', diff --git a/src/main/runtime/kun-runtime-config-service.ts b/src/main/runtime/kun-runtime-config-service.ts index 7ecaff72f..d279f9ad1 100644 --- a/src/main/runtime/kun-runtime-config-service.ts +++ b/src/main/runtime/kun-runtime-config-service.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' +import { isDeepStrictEqual } from 'node:util' import { ContextCompactionConfigSchema, GraphRuntimeConfigSchema, @@ -254,8 +255,19 @@ export async function syncGuiManagedKunConfig( `Refusing to write invalid GUI-managed Kun config at ${configPath}: ${JSON.stringify(parsed.error.issues, null, 2)}` ) } + if (existing) { + const parsedExisting = KunConfigSchema.safeParse(existing) + if ( + parsedExisting.success && + isDeepStrictEqual( + JSON.parse(JSON.stringify(parsed.data)), + JSON.parse(JSON.stringify(parsedExisting.data)) + ) + ) { + return parsed.data + } + } const nextText = `${JSON.stringify(next, null, 2)}\n` - if (existing && nextText === `${JSON.stringify(existing, null, 2)}\n`) return parsed.data try { await atomicWriteFile(configPath, nextText, { beforeCommit: () => { diff --git a/src/main/runtime/kun-serve-replacement.test.ts b/src/main/runtime/kun-serve-replacement.test.ts index 4df562946..c4b69840e 100644 --- a/src/main/runtime/kun-serve-replacement.test.ts +++ b/src/main/runtime/kun-serve-replacement.test.ts @@ -1,10 +1,14 @@ import { describe, expect, it, vi } from 'vitest' +import { resolve } from 'node:path' import type { RuntimeDiscoveryRecord } from '../../../kun/src/server/runtime-discovery.js' import type { ServiceManagerConnection } from '../../../kun/src/manager/manager-client.js' import type { SharedRuntimeInspection } from '../../../kun/src/cli/shared-runtime.js' import { stopSharedRuntimeForReplacement } from './kun-serve-replacement' -const dataDir = '/tmp/kun-replacement-data' +const dataDir = resolve('tmp', 'kun-replacement-data') +const runtimeExecutablePath = process.platform === 'win32' + ? 'C:\\Program Files\\nodejs\\node.exe' + : null const manager: ServiceManagerConnection = { discovery: { @@ -63,7 +67,7 @@ describe('stopSharedRuntimeForReplacement', () => { processIdentity: vi.fn(async () => ({ pid: target.discovery.pid, commandLine: 'kun-runtime', - executablePath: null, + executablePath: runtimeExecutablePath, startedAtMs: Date.parse(target.discovery.startedAt) })), terminate: vi.fn(), @@ -107,7 +111,7 @@ describe('stopSharedRuntimeForReplacement', () => { processIdentity: vi.fn(async () => ({ pid: target.discovery.pid, commandLine: 'kun-runtime', - executablePath: null, + executablePath: runtimeExecutablePath, startedAtMs: Date.parse(target.discovery.startedAt) })), terminate, @@ -154,7 +158,7 @@ describe('stopSharedRuntimeForReplacement', () => { processIdentity: vi.fn(async () => ({ pid: target.discovery.pid, commandLine: 'kun-runtime', - executablePath: null, + executablePath: runtimeExecutablePath, startedAtMs: Date.parse(target.discovery.startedAt) })), terminate, @@ -198,7 +202,7 @@ describe('stopSharedRuntimeForReplacement', () => { processIdentity: vi.fn(async () => ({ pid: target.discovery.pid, commandLine: `node serve-entry.js --data-dir ${dataDir}`, - executablePath: null, + executablePath: runtimeExecutablePath, startedAtMs: Date.parse(target.discovery.startedAt) })), terminate, @@ -236,7 +240,7 @@ describe('stopSharedRuntimeForReplacement', () => { processIdentity: vi.fn(async () => startedAtMs === null ? null : ({ pid: target.discovery.pid, commandLine: command, - executablePath: null, + executablePath: runtimeExecutablePath, startedAtMs })), terminate, @@ -276,7 +280,7 @@ describe('stopSharedRuntimeForReplacement', () => { processIdentity: vi.fn(async () => ({ pid: target.discovery.pid, commandLine: 'kun-runtime', - executablePath: null, + executablePath: runtimeExecutablePath, startedAtMs: Date.parse(target.discovery.startedAt) })), terminate, diff --git a/src/main/settings-store.persistence.test.ts b/src/main/settings-store.persistence.test.ts index 49da48dd0..557d67525 100644 --- a/src/main/settings-store.persistence.test.ts +++ b/src/main/settings-store.persistence.test.ts @@ -191,14 +191,16 @@ it('ignores null entries in persisted Claw channels and schedule tasks', async ( startMinimized: true, useSystemTitleBar: true, closeAction: 'tray', - closeToTray: true + closeToTray: true, + keepAwake: false }) expect(disabled.appBehavior).toEqual({ openAtLogin: false, startMinimized: false, useSystemTitleBar: true, closeAction: 'quit', - closeToTray: false + closeToTray: false, + keepAwake: false }) }) diff --git a/src/main/settings-store.test.ts b/src/main/settings-store.test.ts index 2f0d81393..6b8b34f4d 100644 --- a/src/main/settings-store.test.ts +++ b/src/main/settings-store.test.ts @@ -283,7 +283,8 @@ describe('JsonSettingsStore', () => { startMinimized: false, useSystemTitleBar: false, closeAction: 'ask', - closeToTray: false + closeToTray: false, + keepAwake: false }) }) diff --git a/src/main/ui-plugin-bundled.ts b/src/main/ui-plugin-bundled.ts index 6a317cf9e..afef7e70a 100644 --- a/src/main/ui-plugin-bundled.ts +++ b/src/main/ui-plugin-bundled.ts @@ -1,7 +1,6 @@ import { readFile, stat, writeFile } from 'node:fs/promises' import { mkdir } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' +import { join } from 'node:path' import ikunFigureRef from '../asset/img/ikun.png?url' import ikunRunFigureRef from '../asset/img/ikun_run.png?url' import ikunBobaFigureRef from '../asset/img/ikun_boba.png?url' @@ -10,6 +9,7 @@ import ikunSleepFigureRef from '../asset/img/ikun_sleep.png?url' import ikunStandFigureRef from '../asset/img/ikun_stand.png?url' import { UI_PLUGIN_BUNDLED_IKUN_ID } from '../shared/ui-plugin' import { seedUiPlugin, uiPluginsRootDir } from './services/ui-plugin-service' +import { mainBundleDirectory } from './main-bundle-path' /** * 预装 UI 插件:iKun 模式就是形象工坊的官方示例插件, @@ -53,7 +53,7 @@ const BUNDLED_IKUN_FIGURE_REFS: Record = { } /** bundle 所在目录,用于把 ?url 的 /chunks/xxx.png 还原为真实文件路径 */ -const BUNDLE_DIR = dirname(fileURLToPath(import.meta.url)) +const BUNDLE_DIR = mainBundleDirectory(import.meta.url) /** * 资源引用在打包/开发下可能是: diff --git a/src/main/update-health-argv.ts b/src/main/update-health-argv.ts new file mode 100644 index 000000000..486750a13 --- /dev/null +++ b/src/main/update-health-argv.ts @@ -0,0 +1,27 @@ +export const UPDATE_HEALTH_PATH_ARG = '--kun-update-health-check=' +export const UPDATE_HEALTH_TOKEN_ARG = '--kun-update-health-token=' +export const UPDATE_HEALTH_TARGET_ARG = '--kun-update-target=' + +export function updateHealthCheckRequested(argv = process.argv): boolean { + return argv.some((value) => value.includes(UPDATE_HEALTH_PATH_ARG)) +} + +export function updateHealthArgumentValue( + prefix: string, + argv = process.argv +): string { + for (const argument of argv) { + const offset = argument.indexOf(prefix) + if (offset < 0) continue + const remainder = argument.slice(offset + prefix.length).trimStart() + if (!remainder) return '' + const quote = remainder[0] === '"' || remainder[0] === "'" ? remainder[0] : '' + if (quote) { + const end = remainder.indexOf(quote, 1) + return (end < 0 ? remainder.slice(1) : remainder.slice(1, end)).trim() + } + const nextFlag = remainder.search(/\s+--[A-Za-z0-9-]+=/u) + return (nextFlag < 0 ? remainder : remainder.slice(0, nextFlag)).trim() + } + return '' +} diff --git a/src/main/update-health-bootstrap.test.ts b/src/main/update-health-bootstrap.test.ts new file mode 100644 index 000000000..7ebbc8a5f --- /dev/null +++ b/src/main/update-health-bootstrap.test.ts @@ -0,0 +1,94 @@ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + appendUpdateHealthProgress, + updateHealthDiagnosticBasePath, + writeUpdateHealthBootstrapFailure +} from './update-health-bootstrap' + +describe('update health bootstrap failure', () => { + it('writes a standard failure result and redacts the token', () => { + const root = mkdtempSync(join(tmpdir(), 'kun-health-bootstrap-')) + const resultPath = join(root, 'health.json') + + writeUpdateHealthBootstrapFailure({ + argv: [ + 'Kun.exe', + `--kun-update-health-check="${resultPath}" ` + + '--kun-update-health-token="secret-token" ' + + '--kun-update-target="C:\\Program Files\\Kun"' + ], + error: new Error('chunk load failed'), + executablePath: 'C:\\Program Files\\Kun\\Kun.exe', + resultPath, + target: 'C:\\Program Files\\Kun', + token: 'secret-token', + version: '0.3.9' + }) + + const result = JSON.parse(readFileSync(resultPath, 'utf8')) as Record + expect(result).toMatchObject({ + ok: false, + token: 'secret-token', + installDir: 'C:\\Program Files\\Kun', + version: '0.3.9' + }) + expect(result.message).toContain('chunk load failed') + expect(result.message).toContain('--kun-update-health-token=') + expect(result.message).not.toContain('secret-token') + const progress = readFileSync(`${updateHealthDiagnosticBasePath(resultPath)}.progress.jsonl`, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record) + expect(progress.at(-1)).toMatchObject({ phase: 'failed' }) + expect(JSON.stringify(progress)).not.toContain('secret-token') + }) + + it('keeps a precise terminal result written by the probe', () => { + const root = mkdtempSync(join(tmpdir(), 'kun-health-bootstrap-existing-')) + const resultPath = join(root, 'health.json') + writeFileSync(resultPath, '{"ok":false,"message":"runtime exited"}\n', 'utf8') + + writeUpdateHealthBootstrapFailure({ + argv: ['Kun.exe'], + error: new Error('outer rejection'), + executablePath: 'C:\\Program Files\\Kun\\Kun.exe', + resultPath, + target: 'C:\\Program Files\\Kun', + token: 'secret-token', + version: '0.3.9' + }) + + expect(JSON.parse(readFileSync(resultPath, 'utf8'))).toEqual({ + ok: false, + message: 'runtime exited' + }) + }) + + it('records append-only progress separately from the terminal result', () => { + const root = mkdtempSync(join(tmpdir(), 'kun-health-progress-')) + const resultPath = join(root, 'health.json') + const startedAt = '2026-09-01T00:00:00.000Z' + + appendUpdateHealthProgress({ + resultPath, + target: 'C:\\Program Files\\Kun', + startedAt, + phase: 'bootstrap' + }) + appendUpdateHealthProgress({ + resultPath, + target: 'C:\\Program Files\\Kun', + startedAt, + phase: 'runtime_waiting', + detail: { port: 19001, runtimePid: 321 } + }) + + expect(readFileSync(`${updateHealthDiagnosticBasePath(resultPath)}.progress.jsonl`, 'utf8')) + .toContain('"phase":"runtime_waiting"') + expect(readFileSync(`${updateHealthDiagnosticBasePath(resultPath)}.progress.jsonl`, 'utf8')) + .toContain('"runtimePid":321') + }) +}) diff --git a/src/main/update-health-bootstrap.ts b/src/main/update-health-bootstrap.ts new file mode 100644 index 000000000..d059d3960 --- /dev/null +++ b/src/main/update-health-bootstrap.ts @@ -0,0 +1,77 @@ +import { appendFileSync, existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs' +import { dirname, extname, win32 as win32Path } from 'node:path' + +export const UPDATE_HEALTH_INTERNAL_TIMEOUT_MS = 120_000 + +export type UpdateHealthProgressDetail = Record< + string, + string | number | boolean | null | undefined +> + +export function updateHealthDiagnosticBasePath(resultPath: string): string { + const extension = extname(resultPath) + return extension ? resultPath.slice(0, -extension.length) : resultPath +} + +export function appendUpdateHealthProgress(input: { + detail?: UpdateHealthProgressDetail + phase: string + resultPath: string + startedAt: string + target: string +}): void { + if (!input.resultPath) return + const progressPath = `${updateHealthDiagnosticBasePath(input.resultPath)}.progress.jsonl` + mkdirSync(dirname(progressPath), { recursive: true }) + appendFileSync(progressPath, `${JSON.stringify({ + schemaVersion: 1, + pid: process.pid, + parentPid: process.ppid, + executablePath: process.execPath, + target: input.target, + startedAt: input.startedAt, + phase: input.phase, + at: new Date().toISOString(), + ...input.detail + })}\n`, 'utf8') +} + +export function writeUpdateHealthBootstrapFailure(input: { + argv: string[] + error: unknown + executablePath: string + resultPath: string + startedAt?: string + target: string + token: string + version: string +}): void { + if (!input.resultPath) return + // The probe writes its own precise terminal failure before rejecting. Do not + // replace that result with the less-specific dynamic-import fallback. + if (existsSync(input.resultPath)) return + const message = input.error instanceof Error ? input.error.message : String(input.error) + const safeArgv = input.argv.map((value) => value.replace( + /--kun-update-health-token=(?:"[^"]*"|'[^']*'|\S+)/gu, + '--kun-update-health-token=' + )) + appendUpdateHealthProgress({ + resultPath: input.resultPath, + target: input.target, + startedAt: input.startedAt ?? new Date().toISOString(), + phase: 'failed', + detail: { message: `Update health bootstrap failed: ${message}` } + }) + mkdirSync(dirname(input.resultPath), { recursive: true }) + const temporary = `${input.resultPath}.${process.pid}.tmp` + writeFileSync(temporary, `${JSON.stringify({ + schemaVersion: 1, + ok: false, + token: input.token, + installDir: win32Path.dirname(input.executablePath), + version: input.version, + message: `Update health bootstrap failed: ${message}; argv=${JSON.stringify(safeArgv)}; target=${input.target}`, + at: new Date().toISOString() + })}\n`, 'utf8') + renameSync(temporary, input.resultPath) +} diff --git a/src/main/update-health-check.test.ts b/src/main/update-health-check.test.ts index 16af09fec..44b004d61 100644 --- a/src/main/update-health-check.test.ts +++ b/src/main/update-health-check.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { win32 as win32Path } from 'node:path' +import { updateHealthCheckRequested } from './update-health-argv' const runMinimalUpdateProbe = vi.fn() const mkdir = vi.fn() @@ -41,10 +42,31 @@ describe('update health request', () => { }) }) + it('parses the combined quoted parameter string used by ExecShellAsUser', () => { + expect(readUpdateHealthRequest([ + 'Kun.exe', + '--kun-update-health-check="C:\\Temp\\health.json" ' + + '--kun-update-health-token="token-123" ' + + '--kun-update-target="C:\\Program Files\\Kun"' + ])).toEqual({ + resultPath: 'C:\\Temp\\health.json', + token: 'token-123', + target: 'C:\\Program Files\\Kun' + }) + }) + it('returns null outside update health mode', () => { expect(readUpdateHealthRequest(['Kun.exe'])).toBeNull() }) + it('identifies health-only startup before desktop single-instance setup', () => { + expect(updateHealthCheckRequested([ + 'Kun.exe', + '--kun-update-health-check=C:\\Temp\\health.json' + ])).toBe(true) + expect(updateHealthCheckRequested(['Kun.exe'])).toBe(false) + }) + it('rejects an incomplete health request', () => { expect(() => readUpdateHealthRequest([ 'Kun.exe', @@ -53,14 +75,41 @@ describe('update health request', () => { }) it('runs only the side-effect-free probe', async () => { + const reportProgress = vi.fn() await runUpdateHealthCheck({ resultPath: 'C:\\Temp\\health.json', token: 'token', target: win32Path.dirname(process.execPath) + }, { + deadlineAt: 123_456, + diagnosticBasePath: 'C:\\Temp\\health', + reportProgress }) expect(runMinimalUpdateProbe).toHaveBeenCalledOnce() + expect(runMinimalUpdateProbe).toHaveBeenCalledWith(undefined, { + deadlineAt: 123_456, + diagnosticBasePath: 'C:\\Temp\\health', + reportProgress + }) expect(writeFile).toHaveBeenCalledOnce() + expect(reportProgress).toHaveBeenLastCalledWith('complete') + }) + + it('writes and reports a terminal failure from the probe', async () => { + const reportProgress = vi.fn() + runMinimalUpdateProbe.mockRejectedValueOnce(new Error('runtime exited')) + + await expect(runUpdateHealthCheck({ + resultPath: 'C:\\Temp\\health.json', + token: 'token', + target: win32Path.dirname(process.execPath) + }, { reportProgress })).rejects.toThrow('runtime exited') + + expect(writeFile).toHaveBeenCalledOnce() + expect(reportProgress).toHaveBeenLastCalledWith('failed', { + message: 'runtime exited' + }) }) }) diff --git a/src/main/update-health-check.ts b/src/main/update-health-check.ts index 03c2c6ef2..a71e6f108 100644 --- a/src/main/update-health-check.ts +++ b/src/main/update-health-check.ts @@ -2,10 +2,13 @@ import { app } from 'electron' import { mkdir, rename, writeFile } from 'node:fs/promises' import { dirname, win32 as win32Path } from 'node:path' import { runMinimalUpdateProbe } from './update-health-probe' - -const HEALTH_PATH_ARG = '--kun-update-health-check=' -const HEALTH_TOKEN_ARG = '--kun-update-health-token=' -const HEALTH_TARGET_ARG = '--kun-update-target=' +import type { UpdateHealthProbeProgress } from './update-health-probe' +import { + UPDATE_HEALTH_PATH_ARG, + UPDATE_HEALTH_TARGET_ARG, + UPDATE_HEALTH_TOKEN_ARG, + updateHealthArgumentValue +} from './update-health-argv' type UpdateHealthRequest = { resultPath: string @@ -13,16 +16,11 @@ type UpdateHealthRequest = { target: string } -function argumentValue(prefix: string, argv = process.argv): string { - const argument = argv.find((value) => value.startsWith(prefix)) - return argument ? argument.slice(prefix.length).trim() : '' -} - export function readUpdateHealthRequest(argv = process.argv): UpdateHealthRequest | null { - const resultPath = argumentValue(HEALTH_PATH_ARG, argv) + const resultPath = updateHealthArgumentValue(UPDATE_HEALTH_PATH_ARG, argv) if (!resultPath) return null - const token = argumentValue(HEALTH_TOKEN_ARG, argv) - const target = argumentValue(HEALTH_TARGET_ARG, argv) + const token = updateHealthArgumentValue(UPDATE_HEALTH_TOKEN_ARG, argv) + const target = updateHealthArgumentValue(UPDATE_HEALTH_TARGET_ARG, argv) if (!token || !target) throw new Error('The update health request is incomplete.') return { resultPath, token, target } } @@ -46,18 +44,32 @@ async function writeHealthResult( await rename(temporary, request.resultPath) } -export async function runUpdateHealthCheck(request: UpdateHealthRequest): Promise { +export async function runUpdateHealthCheck( + request: UpdateHealthRequest, + options: { + deadlineAt?: number + diagnosticBasePath?: string + reportProgress?: UpdateHealthProbeProgress + } = {} +): Promise { + const reportProgress = options.reportProgress ?? (() => undefined) try { if (process.platform !== 'win32') throw new Error('Update health checks require Windows.') const installDir = win32Path.dirname(process.execPath) if (win32Path.resolve(installDir).toLowerCase() !== win32Path.resolve(request.target).toLowerCase()) { throw new Error('The candidate executable is outside the committed install target.') } - await runMinimalUpdateProbe() + await runMinimalUpdateProbe(undefined, { + deadlineAt: options.deadlineAt ?? Date.now() + 120_000, + diagnosticBasePath: options.diagnosticBasePath ?? request.resultPath.replace(/\.json$/iu, ''), + reportProgress + }) await writeHealthResult(request, true, 'Candidate application payload is healthy.') + reportProgress('complete') } catch (error) { const message = error instanceof Error ? error.message : String(error) await writeHealthResult(request, false, message) + reportProgress('failed', { message }) throw error } } diff --git a/src/main/update-health-probe.test.ts b/src/main/update-health-probe.test.ts index a782cc3e5..608fd415e 100644 --- a/src/main/update-health-probe.test.ts +++ b/src/main/update-health-probe.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from 'vitest' -import { runMinimalUpdateProbe } from './update-health-probe' +import { join } from 'node:path' +import { + isolatedUpdateHealthRuntimeEnvironment, + registerUpdateHealthRendererIpc, + runMinimalUpdateProbe, + updateHealthRuntimeEnvironment +} from './update-health-probe' vi.mock('electron', () => ({ app: { @@ -27,6 +33,50 @@ describe('runMinimalUpdateProbe', () => { } } + it('runs the bundled runtime entry through Electron\'s Node mode', () => { + expect(updateHealthRuntimeEnvironment('node-script', { EXISTING: 'value' })).toEqual({ + EXISTING: 'value', + ELECTRON_RUN_AS_NODE: '1' + }) + expect(updateHealthRuntimeEnvironment('custom', { EXISTING: 'value' })).toEqual({ + EXISTING: 'value' + }) + }) + + it('isolates the health runtime from the user Service Manager', () => { + const dataDir = join('/tmp', 'health-data') + const managerControlDir = join(dataDir, 'manager', 'control') + const managerSettingsPath = join(dataDir, 'manager', 'settings.json') + expect(isolatedUpdateHealthRuntimeEnvironment({ + dataDir, + managerControlDir, + managerSettingsPath, + resolutionKind: 'node-script', + runtimeToken: 'runtime-token' + }, { KUN_MANAGER_BASE_URL: 'http://inherited.invalid' })).toMatchObject({ + ELECTRON_RUN_AS_NODE: '1', + KUN_MANAGER_BASE_URL: '', + KUN_MANAGER_CONTROL_DIR: managerControlDir, + KUN_MANAGER_DATA_DIR: '', + KUN_MANAGER_SETTINGS_PATH: managerSettingsPath, + KUN_MANAGER_TOKEN: '', + KUN_RUNTIME_DISCOVERY_DIR: join(dataDir, 'runtime-discovery'), + KUN_RUNTIME_TOKEN: 'runtime-token' + }) + }) + + it('registers the preload startup channel used by the hidden renderer', () => { + const handle = vi.fn() + + registerUpdateHealthRendererIpc({ handle } as never) + + expect(handle).toHaveBeenCalledWith('startup:state:get', expect.any(Function)) + expect(handle.mock.calls[0]?.[1]()).toEqual({ + phase: 'bootstrapping', + detail: 'update-health-probe' + }) + }) + it('loads the packaged runtime module without starting persistent services', async () => { const d = deps() @@ -38,6 +88,43 @@ describe('runMinimalUpdateProbe', () => { expect(d.removeTempDir).toHaveBeenCalledWith('/tmp/kun-update-health-1') }) + it('reports bounded progress through renderer and runtime services', async () => { + const d = deps() + const reportProgress = vi.fn() + const context = { + deadlineAt: Date.now() + 10_000, + diagnosticBasePath: '/tmp/health', + reportProgress + } + + await runMinimalUpdateProbe(d, context) + + expect(d.probeRendererWindow).toHaveBeenCalledWith(context) + expect(d.probeRuntimeServices).toHaveBeenCalledWith('/tmp/kun-update-health-1', context) + expect(reportProgress.mock.calls.map(([phase]) => phase)).toEqual([ + 'electron_waiting', + 'electron_ready', + 'payload_checking', + 'payload_ready', + 'runtime_module_loading', + 'runtime_module_ready', + 'renderer_loading', + 'runtime_services_starting' + ]) + }) + + it('rejects immediately after the shared deadline expires', async () => { + const d = deps() + + await expect(runMinimalUpdateProbe(d, { + deadlineAt: Date.now() - 1, + diagnosticBasePath: '/tmp/health', + reportProgress: vi.fn() + })).rejects.toThrow('deadline expired') + + expect(d.loadRuntimeAdapter).not.toHaveBeenCalled() + }) + it('rejects an incomplete candidate payload before loading runtime modules', async () => { const d = deps({ inspectInstall: vi.fn(() => ({ ok: false, missing: ['Kun runtime entry'] })) diff --git a/src/main/update-health-probe.ts b/src/main/update-health-probe.ts index c1655ff54..d30904d8f 100644 --- a/src/main/update-health-probe.ts +++ b/src/main/update-health-probe.ts @@ -1,8 +1,21 @@ import { app } from 'electron' -import { mkdtemp, rm } from 'node:fs/promises' +import { copyFile, mkdir, mkdtemp, open, readFile, rm } from 'node:fs/promises' +import { createServer } from 'node:net' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { inspectPackagedInstallHealth } from './packaged-install-health' +import { mainBundleDirectory } from './main-bundle-path' + +export type UpdateHealthProbeProgress = ( + phase: string, + detail?: Record +) => void + +export type UpdateHealthProbeContext = { + deadlineAt: number + diagnosticBasePath: string + reportProgress: UpdateHealthProbeProgress +} export type UpdateHealthProbeDeps = { isPackaged: () => boolean @@ -11,12 +24,12 @@ export type UpdateHealthProbeDeps = { inspectInstall: typeof inspectPackagedInstallHealth loadRuntimeAdapter: () => Promise /** Probe the renderer surface: preload, renderer entry, and an IPC ping. */ - probeRendererWindow: () => Promise + probeRendererWindow: (context: UpdateHealthProbeContext) => Promise /** * Exercise the runtime against an isolated temporary data directory: * start the gateway, create a throwaway thread, read it back, delete it. */ - probeRuntimeServices: (dataDir: string) => Promise + probeRuntimeServices: (dataDir: string, context: UpdateHealthProbeContext) => Promise createTempDir: () => Promise removeTempDir: (dir: string) => Promise } @@ -29,6 +42,61 @@ async function defaultRemoveTempDir(dir: string): Promise { await rm(dir, { recursive: true, force: true }) } +function timeoutFor(context: UpdateHealthProbeContext, maximumMs?: number): number { + const remaining = context.deadlineAt - Date.now() + if (remaining <= 0) throw new Error('The update health deadline expired.') + return Math.max(1, Math.min(remaining, maximumMs ?? remaining)) +} + +async function withHealthDeadline( + work: Promise, + context: UpdateHealthProbeContext, + label: string, + maximumMs?: number +): Promise { + const timeoutMs = timeoutFor(context, maximumMs) + let timer: ReturnType | undefined + try { + return await Promise.race([ + work, + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new Error(`${label} exceeded its ${timeoutMs} ms health-check budget.`)) + }, timeoutMs) + timer.unref?.() + }) + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +async function waitForHealthDelay( + context: UpdateHealthProbeContext, + maximumMs: number +): Promise { + const delayMs = timeoutFor(context, maximumMs) + await new Promise((resolve) => { + const timer = setTimeout(resolve, delayMs) + timer.unref?.() + }) +} + +async function finishCleanup(work: Promise, maximumMs = 5_000): Promise { + let timer: ReturnType | undefined + try { + await Promise.race([ + work.catch(() => undefined), + new Promise((resolve) => { + timer = setTimeout(resolve, maximumMs) + timer.unref?.() + }) + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + const defaultDeps: UpdateHealthProbeDeps = { isPackaged: () => app.isPackaged, executablePath: () => process.execPath, @@ -42,6 +110,21 @@ const defaultDeps: UpdateHealthProbeDeps = { } let rendererProbeRegistered = false +const HEALTH_BUNDLE_DIR = mainBundleDirectory(import.meta.url) + +export function registerUpdateHealthRendererIpc( + main: Pick +): void { + if (rendererProbeRegistered) return + // The preload bridge exposes startup.getState() over this existing channel. + // Registering an unrelated health-only channel leaves the invoke pending in + // health mode because the normal shell IPC surface is intentionally absent. + main.handle('startup:state:get', () => ({ + phase: 'bootstrapping', + detail: 'update-health-probe' + })) + rendererProbeRegistered = true +} /** * Renderer-surface probe: load the production preload and renderer entry in a @@ -49,14 +132,11 @@ let rendererProbeRegistered = false * missing preload builds, broken renderer chunks, and dead IPC channels before * the installer commits the payload switch. */ -async function defaultProbeRendererWindow(): Promise { +async function defaultProbeRendererWindow(context: UpdateHealthProbeContext): Promise { const { BrowserWindow, ipcMain } = await import('electron') - if (!rendererProbeRegistered) { - ipcMain.handle('kun-update-health-probe:ping', () => ({ ok: true, at: Date.now() })) - rendererProbeRegistered = true - } + registerUpdateHealthRendererIpc(ipcMain) const preloadPath = (await import('./main-paths')).resolveNamedPreloadPath( - join(__dirname), 'index' + HEALTH_BUNDLE_DIR, 'index' ) const window = new BrowserWindow({ show: false, @@ -67,26 +147,199 @@ async function defaultProbeRendererWindow(): Promise { } }) try { - await window.loadFile(join(__dirname, '../renderer/index.html')) - const pong = await window.webContents.executeJavaScript( - 'window.kunGui ? window.kunGui.startup.getState() : Promise.reject(new Error("preload bridge missing"))', - true + await withHealthDeadline( + window.loadFile(join(HEALTH_BUNDLE_DIR, '../renderer/index.html')), + context, + 'Renderer file load', + 30_000 + ) + context.reportProgress('renderer_loaded') + context.reportProgress('renderer_ipc_checking') + const pong = await withHealthDeadline( + window.webContents.executeJavaScript( + 'window.kunGui ? window.kunGui.startup.getState() : Promise.reject(new Error("preload bridge missing"))', + true + ), + context, + 'Renderer IPC bootstrap', + 15_000 ) - if (!pong || typeof pong !== 'object') { + if (!pong || typeof pong !== 'object' || pong.phase !== 'bootstrapping') { throw new Error('The renderer IPC ping returned an invalid payload.') } + context.reportProgress('renderer_ready') } finally { window.destroy() } } +async function findAvailableLoopbackPort(context: UpdateHealthProbeContext): Promise { + const server = createServer() + try { + await withHealthDeadline(new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => resolve()) + }), context, 'Runtime probe port selection', 5_000) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('The runtime probe could not reserve a loopback port.') + } + return address.port + } finally { + await new Promise((resolve) => server.close(() => resolve())).catch(() => undefined) + } +} + +type RuntimeExit = { + code: number | null + error?: Error + signal: NodeJS.Signals | null +} + +function runtimeExitError(exit: RuntimeExit): Error { + if (exit.error) return new Error(`The candidate runtime failed to start: ${exit.error.message}`) + return new Error( + `The candidate runtime exited before the probe completed ` + + `(code=${exit.code ?? 'null'}, signal=${exit.signal ?? 'null'}).` + ) +} + +type IsolatedManagerDiscovery = { + baseUrl: string + instanceId: string + managerToken: string + pid: number +} + +function safeIsolatedManagerDiscovery(value: unknown): IsolatedManagerDiscovery | null { + if (!value || typeof value !== 'object') return null + const candidate = value as Partial + if ( + typeof candidate.baseUrl !== 'string' || + typeof candidate.instanceId !== 'string' || + typeof candidate.managerToken !== 'string' || + !Number.isInteger(candidate.pid) || + Number(candidate.pid) <= 0 + ) return null + try { + const url = new URL(candidate.baseUrl) + if ( + url.protocol !== 'http:' || + !['127.0.0.1', '::1', '[::1]', 'localhost'].includes(url.hostname) + ) { + return null + } + } catch { + return null + } + return candidate as IsolatedManagerDiscovery +} + +async function shutdownIsolatedHealthManager( + controlDir: string, + context: UpdateHealthProbeContext +): Promise { + const managerLogPath = join(controlDir, 'manager.log') + await copyFile(managerLogPath, `${context.diagnosticBasePath}.manager.log`).catch(() => undefined) + let discovery: IsolatedManagerDiscovery | null = null + try { + discovery = safeIsolatedManagerDiscovery(JSON.parse( + await readFile(join(controlDir, 'manager.json'), 'utf8') + )) + } catch { + return + } + if (!discovery) return + context.reportProgress('manager_stopping', { managerPid: discovery.pid }) + let shutdownAccepted = false + try { + const response = await fetch(`${discovery.baseUrl.replace(/\/$/u, '')}/v1/manager/shutdown`, { + method: 'POST', + headers: { + Authorization: `Bearer ${discovery.managerToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ instanceId: discovery.instanceId }), + signal: AbortSignal.timeout(5_000) + }) + shutdownAccepted = response.ok + } catch { + // The manager can close its socket before the response is observed. + } + for (let attempt = 0; attempt < 25; attempt += 1) { + try { + process.kill(discovery.pid, 0) + } catch { + context.reportProgress('manager_stopped', { managerPid: discovery.pid }) + return + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + if (shutdownAccepted) { + try { + process.kill(discovery.pid, 'SIGTERM') + context.reportProgress('manager_force_stop_requested', { managerPid: discovery.pid }) + } catch { + context.reportProgress('manager_stopped', { managerPid: discovery.pid }) + return + } + for (let attempt = 0; attempt < 10; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 100)) + try { + process.kill(discovery.pid, 0) + } catch { + context.reportProgress('manager_stopped', { managerPid: discovery.pid }) + return + } + } + } + context.reportProgress('manager_stop_incomplete', { managerPid: discovery.pid }) +} + +export function updateHealthRuntimeEnvironment( + resolutionKind: 'custom' | 'node-script', + source: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv { + return { + ...source, + ...(resolutionKind === 'node-script' ? { ELECTRON_RUN_AS_NODE: '1' } : {}) + } +} + +export function isolatedUpdateHealthRuntimeEnvironment( + input: { + dataDir: string + managerControlDir: string + managerSettingsPath: string + resolutionKind: 'custom' | 'node-script' + runtimeToken: string + }, + source: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv { + return { + ...updateHealthRuntimeEnvironment(input.resolutionKind, source), + KUN_MANAGER_BASE_URL: '', + KUN_MANAGER_CONTROL_DIR: input.managerControlDir, + KUN_MANAGER_DATA_DIR: '', + KUN_MANAGER_INSTANCE_ID: '', + KUN_MANAGER_SETTINGS_PATH: input.managerSettingsPath, + KUN_MANAGER_TOKEN: '', + KUN_RUNTIME_LAUNCH_MODE: 'update-health-probe', + KUN_RUNTIME_DISCOVERY_DIR: join(input.dataDir, 'runtime-discovery'), + KUN_RUNTIME_TOKEN: input.runtimeToken + } +} + /** * Runtime-service probe against an isolated data directory: no user data is * touched. A throwaway thread is created, read back, and deleted through the * local gateway to prove the adapter, HTTP surface, and storage all work in * the candidate payload. */ -async function defaultProbeRuntimeServices(dataDir: string): Promise { +async function defaultProbeRuntimeServices( + dataDir: string, + context: UpdateHealthProbeContext +): Promise { const { spawn } = await import('node:child_process') const { resolveKunExecutableForCurrentApp } = await import('./kun-process') const { resolveKunRuntimeBuildId } = await import('./resolve-kun-binary') @@ -94,8 +347,18 @@ async function defaultProbeRuntimeServices(dataDir: string): Promise { const buildId = await resolveKunRuntimeBuildId(resolution) if (!buildId) throw new Error('The candidate Kun Runtime build identity is missing.') - const port = 18991 + const port = await findAvailableLoopbackPort(context) const token = `probe-${Date.now()}-${Math.random().toString(36).slice(2)}` + const stdoutPath = `${context.diagnosticBasePath}.runtime.stdout.log` + const stderrPath = `${context.diagnosticBasePath}.runtime.stderr.log` + const managerControlDir = join(dataDir, 'manager', 'control') + const managerSettingsPath = join(dataDir, 'manager', 'kun-settings.json') + await mkdir(dirname(stdoutPath), { recursive: true }) + const [stdoutLog, stderrLog] = await Promise.all([ + open(stdoutPath, 'a'), + open(stderrPath, 'a') + ]) + context.reportProgress('runtime_spawning', { port, stdoutPath, stderrPath }) const child = spawn(resolution.command, [ ...resolution.args, 'serve', @@ -105,60 +368,135 @@ async function defaultProbeRuntimeServices(dataDir: string): Promise { '--approval-policy', 'never', '--sandbox-mode', 'read-only', '--approval-reviewer', 'user', - '--token-economy-mode', 'false', - '--runtime-token', token - ], { stdio: 'ignore', windowsHide: true }) + '--token-economy-mode', 'false' + ], { + env: isolatedUpdateHealthRuntimeEnvironment({ + dataDir, + managerControlDir, + managerSettingsPath, + resolutionKind: resolution.kind, + runtimeToken: token + }), + stdio: ['ignore', stdoutLog.fd, stderrLog.fd], + windowsHide: true + }) + let runtimeExit: RuntimeExit | undefined + const runtimeExited = new Promise((resolve) => { + child.once('error', (error) => { + runtimeExit = { code: null, error, signal: null } + resolve(runtimeExit) + }) + child.once('exit', (code, signal) => { + runtimeExit = { code, signal } + resolve(runtimeExit) + }) + }) try { + await withHealthDeadline(Promise.race([ + new Promise((resolve) => child.once('spawn', () => resolve())), + runtimeExited.then((exit) => { throw runtimeExitError(exit) }) + ]), context, 'Runtime process spawn', 10_000) + context.reportProgress('runtime_waiting', { + port, + runtimePid: child.pid ?? null, + stdoutPath, + stderrPath + }) const base = `http://127.0.0.1:${port}` const headers = { Authorization: `Bearer ${token}` } let ready = false - for (let attempt = 0; attempt < 120; attempt += 1) { - try { - const response = await fetch(`${base}/health`, { headers, signal: AbortSignal.timeout(2_000) }) - if (response.ok) { ready = true; break } - } catch { - // keep polling until the gateway listens or the budget is exhausted + while (Date.now() < context.deadlineAt) { + const outcome = await Promise.race([ + fetch(`${base}/health`, { + headers, + signal: AbortSignal.timeout(timeoutFor(context, 2_000)) + }).then( + (response) => ({ response }), + () => ({ response: undefined }) + ), + runtimeExited.then((exit) => ({ exit })) + ]) + if ('exit' in outcome) { + context.reportProgress('runtime_exited', { + exitCode: outcome.exit.code, + exitSignal: outcome.exit.signal + }) + throw runtimeExitError(outcome.exit) } - await new Promise((resolveTimeout) => { setTimeout(resolveTimeout, 500).unref?.() }) + if (outcome.response?.ok) { ready = true; break } + await waitForHealthDelay(context, 500) } if (!ready) throw new Error('The candidate runtime gateway did not become healthy.') + context.reportProgress('runtime_ready', { port, runtimePid: child.pid ?? null }) + + const fetchWhileRunning = async ( + url: string, + init: RequestInit, + label: string + ): Promise => { + if (runtimeExit) throw runtimeExitError(runtimeExit) + return withHealthDeadline(Promise.race([ + fetch(url, { + ...init, + signal: AbortSignal.timeout(timeoutFor(context, 15_000)) + }), + runtimeExited.then((exit) => { throw runtimeExitError(exit) }) + ]), context, label, 15_000) + } - const createResponse = await fetch(`${base}/v1/threads`, { + context.reportProgress('thread_creating') + const createResponse = await fetchWhileRunning(`${base}/v1/threads`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'update-health-probe', workspace: dataDir, model: 'probe-model' - }), - signal: AbortSignal.timeout(15_000) - }) + }) + }, 'Runtime thread creation') if (createResponse.status !== 201) { throw new Error(`The candidate runtime could not create a thread (${createResponse.status}).`) } const created = await createResponse.json() as { id?: string } if (!created?.id) throw new Error('The candidate runtime returned a thread without an id.') - const readResponse = await fetch(`${base}/v1/threads/${created.id}`, { - headers, signal: AbortSignal.timeout(15_000) - }) + context.reportProgress('thread_reading') + const readResponse = await fetchWhileRunning(`${base}/v1/threads/${created.id}`, { + headers + }, 'Runtime thread read') if (!readResponse.ok) { throw new Error(`The candidate runtime could not read the probe thread (${readResponse.status}).`) } - const deleteResponse = await fetch(`${base}/v1/threads/${created.id}`, { - method: 'DELETE', headers, signal: AbortSignal.timeout(15_000) - }) + context.reportProgress('thread_deleting') + const deleteResponse = await fetchWhileRunning(`${base}/v1/threads/${created.id}`, { + method: 'DELETE', headers + }, 'Runtime thread deletion') if (!deleteResponse.ok && deleteResponse.status !== 404) { throw new Error(`The candidate runtime could not delete the probe thread (${deleteResponse.status}).`) } } finally { - child.kill() - await new Promise((resolveExit) => { - const timer = setTimeout(() => resolveExit(), 5_000) - timer.unref?.() - child.once('exit', () => { clearTimeout(timer); resolveExit() }) - }) + if (!runtimeExit) child.kill() + if (!runtimeExit) { + await new Promise((resolveExit) => { + const timer = setTimeout(() => resolveExit(), 5_000) + timer.unref?.() + child.once('exit', () => { clearTimeout(timer); resolveExit() }) + }) + } + if (!runtimeExit && child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL') + await Promise.race([ + runtimeExited, + new Promise((resolve) => setTimeout(resolve, 2_000)) + ]) + } + await Promise.all([ + stdoutLog.close().catch(() => undefined), + stderrLog.close().catch(() => undefined) + ]) + context.reportProgress('runtime_stopped', { runtimePid: child.pid ?? null }) + await shutdownIsolatedHealthManager(managerControlDir, context) } } @@ -171,12 +509,20 @@ async function defaultProbeRuntimeServices(dataDir: string): Promise { * intentionally begin on the first normal launch after the commit succeeds. */ export async function runMinimalUpdateProbe( - deps: UpdateHealthProbeDeps = defaultDeps + deps: UpdateHealthProbeDeps = defaultDeps, + context: UpdateHealthProbeContext = { + deadlineAt: Date.now() + 120_000, + diagnosticBasePath: join(tmpdir(), `kun-update-health-${process.pid}`), + reportProgress: () => undefined + } ): Promise { - await app.whenReady() + context.reportProgress('electron_waiting') + await withHealthDeadline(app.whenReady(), context, 'Electron readiness', 15_000) + context.reportProgress('electron_ready') // Calling getVersion confirms Electron's main-process binding is available. app.getVersion() + context.reportProgress('payload_checking') const installHealth = deps.inspectInstall({ isPackaged: deps.isPackaged(), executablePath: deps.executablePath(), @@ -185,19 +531,28 @@ export async function runMinimalUpdateProbe( if (!installHealth.ok) { throw new Error(`Kun installation is incomplete (${installHealth.missing.join(', ')}).`) } + context.reportProgress('payload_ready') // This verifies the packaged runtime module graph without resolving settings, // starting a Manager/Runtime, or touching user data. - await deps.loadRuntimeAdapter() + context.reportProgress('runtime_module_loading') + await withHealthDeadline( + deps.loadRuntimeAdapter(), context, 'Runtime adapter module load', 15_000 + ) + context.reportProgress('runtime_module_ready') // Renderer surface: preload bridge, renderer entry, and one IPC round trip. - await deps.probeRendererWindow() + context.reportProgress('renderer_loading') + await deps.probeRendererWindow(context) // Runtime services against an isolated data directory; never user data. - const dataDir = await deps.createTempDir() + const dataDir = await withHealthDeadline( + deps.createTempDir(), context, 'Runtime probe data-directory creation', 5_000 + ) try { - await deps.probeRuntimeServices(dataDir) + context.reportProgress('runtime_services_starting') + await deps.probeRuntimeServices(dataDir, context) } finally { - await deps.removeTempDir(dataDir).catch(() => undefined) + await finishCleanup(deps.removeTempDir(dataDir)) } } diff --git a/src/main/windows-installer-migration.recovery-safety.test.ts b/src/main/windows-installer-migration.recovery-safety.test.ts index 09b538e76..dfd85b230 100644 --- a/src/main/windows-installer-migration.recovery-safety.test.ts +++ b/src/main/windows-installer-migration.recovery-safety.test.ts @@ -254,7 +254,7 @@ it('preserves unknown top-level content and restores it after fallback cleanup', .toBe('personal') }) - it('rejects an existing journal under a cross-user-writable directory', () => { + it('quarantines an existing journal under a cross-user-writable directory', () => { const root = makeTempRoot() const source = join(root, 'Kun') const recoveryDirectory = join(root, 'recovery') @@ -280,8 +280,11 @@ it('preserves unknown top-level content and restores it after fallback cleanup', const recovery = runHelper({ action: 'Recover', source, target: source, journal }) - expect(recovery.status).not.toBe(0) - expect(processError(recovery)).toContain('untrusted ACL') + expect(recovery.status, processError(recovery)).toBe(0) + expect(existsSync(journal)).toBe(false) + expect(readdirSync(recoveryDirectory).filter((name) => + /^journal\.json\.untrusted-[a-f0-9]{32}$/u.test(name) + )).toHaveLength(1) expect(readFileSync(join(source, 'personal.txt'), 'utf8')).toBe('do not move') }) diff --git a/src/main/windows-installer-migration.source-resolution.test.ts b/src/main/windows-installer-migration.source-resolution.test.ts index 7e4aa7c47..c8b645608 100644 --- a/src/main/windows-installer-migration.source-resolution.test.ts +++ b/src/main/windows-installer-migration.source-resolution.test.ts @@ -21,7 +21,8 @@ const helperModulePaths = [ 'windows-installer-migration-journal.ps1', 'windows-installer-migration-filesystem.ps1', 'windows-installer-migration-actions.ps1', - 'windows-installer-migration-recovery-env.ps1' + 'windows-installer-migration-recovery-env.ps1', + 'windows-installer-migration-transaction.ps1' ].map((fileName) => join(process.cwd(), 'build', fileName)) const smokePath = join(process.cwd(), 'scripts/smoke-windows-installer-migration.ps1') const windowsOnly = process.platform === 'win32' ? describe : describe.skip @@ -276,6 +277,9 @@ it('lets an explicit target override a registered legacy branded source', () => const resultPath = join(root, 'kun-windows-installer-result.txt') mkdirSync(source, { recursive: true }) copyFileSync(helperPath, copiedHelper) + for (const modulePath of helperModulePaths) { + copyFileSync(modulePath, join(root, parse(modulePath).base)) + } const canonicalSource = realpathSync.native(source) const result = runHelper({ diff --git a/src/main/windows-installer-migration.test.ts b/src/main/windows-installer-migration.test.ts index 6bd0883c0..7c7b0cabf 100644 --- a/src/main/windows-installer-migration.test.ts +++ b/src/main/windows-installer-migration.test.ts @@ -232,6 +232,7 @@ describe('Windows installer migration ACL contract', () => { it('migrates trusted schema-2 journals and cleans stale journals during uninstall', () => { const script = readHelperSources() const installerScript = readFileSync(join(process.cwd(), 'build/installer.nsh'), 'utf8') + .replace(/\r\n/g, '\n') expect(script).toContain('function Convert-LegacyJournal') expect(script).toContain("if ($schemaVersion -ne 2)") diff --git a/src/main/windows-installer-migration.transaction.test.ts b/src/main/windows-installer-migration.transaction.test.ts index 476120455..2d63f6481 100644 --- a/src/main/windows-installer-migration.transaction.test.ts +++ b/src/main/windows-installer-migration.transaction.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process' -import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -11,7 +11,7 @@ const artifactRoot = process.env.KUN_INSTALLER_TEST_ARTIFACT_ROOT let fixtureIndex = 0 type Fixture = ReturnType -type Action = 'Prepare' | 'SwitchUpdatePayload' | 'RollbackUpdateTransaction' | 'Restore' | 'UpdatePath' | 'ValidateHealthResult' | 'CommitUpdateTransaction' | 'FinalizeUpdateTransaction' | 'RecoverUpdateTransaction' +type Action = 'Prepare' | 'SwitchUpdatePayload' | 'ValidateCutover' | 'RollbackUpdateTransaction' | 'Restore' | 'UpdatePath' | 'ValidateHealthResult' | 'CommitUpdateTransaction' | 'FinalizeUpdateTransaction' | 'RecoverUpdateTransaction' function payload(root: string, executable: string): void { mkdirSync(join(root, 'resources', 'app.asar.unpacked', 'kun', 'dist', 'cli'), { recursive: true }) @@ -111,6 +111,24 @@ function powershell(command: string): ReturnType { return spawnSync('powershell.exe', ['-NoProfile', '-Command', command], { encoding: 'utf8' }) } +function primeCutoverMetadata(input: Fixture): void { + const quoted = (value: string) => value.replace(/'/g, "''") + const target = quoted(input.target) + const executable = quoted(join(input.target, 'Kun.exe')) + const shortcut = quoted(join(input.desktop, 'Kun.lnk')) + assertSucceeded(powershell(` + foreach($name in @('Install','Uninstall')){ + $key=[Microsoft.Win32.Registry]::CurrentUser.CreateSubKey("Software\\KunInstallerTransactionTest\\$name",$true) + $key.SetValue('InstallLocation','${target}',[Microsoft.Win32.RegistryValueKind]::String) + $key.Dispose() + } + $shell=New-Object -ComObject WScript.Shell + $link=$shell.CreateShortcut('${shortcut}') + $link.TargetPath='${executable}' + $link.Save() + `), 'prime cutover metadata') +} + function processSummary(result: ReturnType, action: string): string { return [ `Windows migration action: ${action}`, @@ -198,7 +216,7 @@ windowsOnly('Windows automatic update transaction', () => { expect(state.Shortcuts).toEqual([]) expect(state.InPlace).toBe(false) expect(existsSync(state.BackupRoot)).toBe(false) - expect(state.RecoveryExecutable).toBe(join(input.source, 'DeepSeek GUI.exe')) + expect(state.RecoveryExecutable).toBe(realpathSync.native(join(input.source, 'DeepSeek GUI.exe'))) payload(input.stage, 'Kun.exe') const switched = run(input, 'SwitchUpdatePayload', 'validate.before_check') assertExpectedFailure(switched, 'SwitchUpdatePayload') @@ -412,7 +430,7 @@ windowsOnly('Windows automatic update transaction', () => { ], { encoding: 'utf8' }).stdout.trim() expect(current).toBe(originalPath.trim()) expect(existsSync(join(input.source, 'DeepSeek GUI.exe'))).toBe(true) - }) + }, 120_000) }) windowsOnly('Windows automatic update fault injection points', () => { @@ -426,7 +444,7 @@ windowsOnly('Windows automatic update fault injection points', () => { assertExpectedFailure(failed, 'Prepare') const state = readTransaction(input) expect(state.Phase).toBe('prepared') - expect(state.JournalPath).toBe(input.journal) + expect(state.JournalPath).toBe(join(realpathSync.native(join(input.root, 'recovery')), 'journal.json')) expect(state.OldVersion).toBe('0.1.0') expect(state.NewVersion).toBe('0.2.0') expect(String(state.Target)).toContain('Kun') @@ -438,7 +456,7 @@ windowsOnly('Windows automatic update fault injection points', () => { }) it('restores the old payload when interrupted mid-move (switch.after_old_move)', () => { - const input = fixture() + const input = fixture(true) assertSucceeded(run(input, 'Prepare'), 'Prepare') payload(input.stage, 'Kun.exe') const failed = run(input, 'SwitchUpdatePayload', 'switch.after_old_move') @@ -447,8 +465,7 @@ windowsOnly('Windows automatic update fault injection points', () => { expect(state.Phase).toBe('prepared') assertSucceeded(run(input, 'RecoverUpdateTransaction'), 'RecoverUpdateTransaction') expect(readFileSync(join(input.source, 'notes.txt'), 'utf8')).toBe('preserved user file') - expect(existsSync(join(input.source, 'DeepSeek GUI.exe'))).toBe(true) - expect(existsSync(input.target)).toBe(false) + expect(existsSync(join(input.source, 'Kun.exe'))).toBe(true) }) it('rolls back a payload that switched but never validated cutover (switch.after_payload_switched)', () => { @@ -468,8 +485,9 @@ windowsOnly('Windows automatic update fault injection points', () => { assertSucceeded(run(input, 'Prepare'), 'Prepare') payload(input.stage, 'Kun.exe') assertSucceeded(run(input, 'SwitchUpdatePayload'), 'SwitchUpdatePayload') - const failed = run(input, 'Restore', 'cutover.after_awaiting_health') - assertExpectedFailure(failed, 'Restore') + primeCutoverMetadata(input) + const failed = run(input, 'ValidateCutover', 'cutover.after_awaiting_health') + assertExpectedFailure(failed, 'ValidateCutover') expect(readTransaction(input).Phase).toBe('awaiting_health') assertSucceeded(run(input, 'RecoverUpdateTransaction'), 'RecoverUpdateTransaction') expect(readTransaction(input)).toMatchObject({ Phase: 'rolled_back', RollbackOutcome: 'succeeded' }) diff --git a/src/renderer/src/agent/kun-runtime.test.ts b/src/renderer/src/agent/kun-runtime.test.ts index d6b15aa57..627ceb118 100644 --- a/src/renderer/src/agent/kun-runtime.test.ts +++ b/src/renderer/src/agent/kun-runtime.test.ts @@ -39,7 +39,7 @@ describe('KunRuntimeProvider', () => { ) }) - it('does not impose a hidden limit when listing the full thread inventory', async () => { + it('paginates the full thread inventory with the maximum page size', async () => { const runtimeRequest = vi.fn(async () => ({ ok: true, status: 200, @@ -51,7 +51,7 @@ describe('KunRuntimeProvider', () => { await provider.listThreads({ includeArchived: true }) expect(runtimeRequest).toHaveBeenCalledWith( - '/v1/threads?include_archived=true', + '/v1/threads?limit=500&include_archived=true', 'GET' ) }) diff --git a/src/renderer/src/components/chat/FloatingComposer.capabilities-skills.test.ts b/src/renderer/src/components/chat/FloatingComposer.capabilities-skills.test.ts index a71b26d53..86654086e 100644 --- a/src/renderer/src/components/chat/FloatingComposer.capabilities-skills.test.ts +++ b/src/renderer/src/components/chat/FloatingComposer.capabilities-skills.test.ts @@ -339,7 +339,7 @@ describe('FloatingComposer capability controls', () => { ) expect(html).toContain('deepseek-v4-pro') - expect(html).toContain('Stop') + expect(html).toContain('Queue message') const modelTrigger = html.match(/]*aria-label="Model: DeepSeek \/ deepseek-v4-pro"[^>]*>/)?.[0] const reasoningTrigger = html.match(/]*aria-label="Reasoning: High"[^>]*>/)?.[0] expect(modelTrigger).toBeDefined() diff --git a/src/renderer/src/components/chat/SidebarProjectRows.task-surface.test.ts b/src/renderer/src/components/chat/SidebarProjectRows.task-surface.test.ts index 2cbc11e6e..d7302fd3e 100644 --- a/src/renderer/src/components/chat/SidebarProjectRows.task-surface.test.ts +++ b/src/renderer/src/components/chat/SidebarProjectRows.task-surface.test.ts @@ -6,6 +6,7 @@ import type { NormalizedThread } from '../../agent/types' import { ThreadRow } from './SidebarProjectRows' vi.mock('react-i18next', () => ({ + initReactI18next: { type: '3rdParty', init: vi.fn() }, useTranslation: () => ({ t: (key: string) => key }) })) diff --git a/src/renderer/src/components/chat/StreamdownCode.test.ts b/src/renderer/src/components/chat/StreamdownCode.test.ts index 68b6f43c9..c4136d706 100644 --- a/src/renderer/src/components/chat/StreamdownCode.test.ts +++ b/src/renderer/src/components/chat/StreamdownCode.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' +import '../../i18n' import { StreamdownCode } from './StreamdownCode' describe('StreamdownCode plain text fences', () => { diff --git a/src/renderer/src/components/chat/message-timeline-cards.test.ts b/src/renderer/src/components/chat/message-timeline-cards.test.ts index 75f3b35a7..039ece129 100644 --- a/src/renderer/src/components/chat/message-timeline-cards.test.ts +++ b/src/renderer/src/components/chat/message-timeline-cards.test.ts @@ -164,8 +164,8 @@ describe('plan build actions', () => { const actions = renderer!.root.findByProps({ 'data-plan-build-actions-variant': 'panel' }) const graphButtons = renderer!.root.findAllByType('button').filter((button) => nodeText(button.props.children).includes('Graph build')) - expect(actions.props.className).toContain('grid-cols-1') - expect(buttonWithText(renderer!, 'Direct build').props.disabled).toBe(false) + expect(actions.props.className).toContain('flex-wrap') + expect(renderer!.root.findByProps({ 'data-plan-build-direct': true }).props.disabled).toBe(false) expect(graphButtons).toHaveLength(0) act(() => renderer!.unmount()) @@ -186,7 +186,7 @@ describe('plan build actions', () => { }) expect(renderer!.root.findAllByProps({ role: 'switch' })).toHaveLength(0) - expect(buttonWithText(renderer!, 'Direct build').props.disabled).toBe(false) + expect(renderer!.root.findByProps({ 'data-plan-build-direct': true }).props.disabled).toBe(false) act(() => renderer!.unmount()) }) @@ -213,28 +213,30 @@ describe('plan build actions', () => { )) }) + const panelSelect = renderer!.root.findByProps({ 'data-plan-worktree-select': true }) const switches = renderer!.root.findAllByProps({ role: 'switch' }) - expect(switches.map((item) => item.props['aria-checked'])).toEqual([true, true]) + expect(panelSelect.props.value).toBe('worktree') + expect(switches.map((item) => item.props['aria-checked'])).toEqual([true]) await act(async () => switches[0]!.props.onClick()) + expect(renderer!.root.findByProps({ 'data-plan-worktree-select': true }).props.value).toBe('workspace') expect(renderer!.root.findAllByProps({ role: 'switch' }) - .map((item) => item.props['aria-checked'])).toEqual([false, false]) + .map((item) => item.props['aria-checked'])).toEqual([false]) expect(renderer!.root.findAllByProps({ 'data-plan-build-mode': true })) .toHaveLength(1) const cardMode = renderer!.root.findByProps({ 'data-plan-build-mode': true }) await act(async () => cardMode.props.onChange({ target: { value: 'graph' } })) const graphSwitches = renderer!.root.findAllByProps({ role: 'switch' }) - expect(graphSwitches[0]!.props.disabled).toBe(false) - expect(graphSwitches[1]!.props.disabled).toBe(true) + expect(graphSwitches[0]!.props.disabled).toBe(true) expect(JSON.stringify(renderer!.toJSON())).toContain( 'Prompt-managed worktrees are available for Direct builds only' ) await act(async () => renderer!.root.findByProps({ 'data-plan-build-mode': true }).props.onChange({ target: { value: 'direct' } })) - expect(renderer!.root.findAllByProps({ role: 'switch' })[1]!.props.disabled).toBe(false) + expect(renderer!.root.findAllByProps({ role: 'switch' })[0]!.props.disabled).toBe(false) expect(renderer!.root.findAllByProps({ role: 'switch' }) - .map((item) => item.props['aria-checked'])).toEqual([false, false]) + .map((item) => item.props['aria-checked'])).toEqual([false]) act(() => renderer!.unmount()) }) diff --git a/src/renderer/src/components/design/DesignAIRail.test.ts b/src/renderer/src/components/design/DesignAIRail.test.ts index 5d22f1a28..577153cf0 100644 --- a/src/renderer/src/components/design/DesignAIRail.test.ts +++ b/src/renderer/src/components/design/DesignAIRail.test.ts @@ -109,7 +109,7 @@ describe('DesignAIRail target toggle', () => { expect(html).toContain('aria-pressed="true"') }) - it('offers guidance for queued plain-text messages', () => { + it('exposes queued plain-text messages from the collapsed queue', () => { const html = renderToStaticMarkup(createElement(DesignAIRail, props({ busy: true, activeThreadId: 'thread-current-document', @@ -131,9 +131,7 @@ 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).toContain('aria-label="1 queued"') expect(html).toMatch(/]*disabled=""[^>]*aria-label="Clear history"/) }) diff --git a/src/renderer/src/components/design/canvas/CanvasToolbar.test.ts b/src/renderer/src/components/design/canvas/CanvasToolbar.test.ts index 032351f64..fc72aa580 100644 --- a/src/renderer/src/components/design/canvas/CanvasToolbar.test.ts +++ b/src/renderer/src/components/design/canvas/CanvasToolbar.test.ts @@ -118,7 +118,7 @@ describe('CanvasToolbar prototype playback', () => { }) ) }) - await act(async () => renderer.root.findByProps({ 'aria-label': 'Open design assistant' }).props.onClick()) + await act(async () => renderer.root.findByProps({ 'aria-label': 'Critique canvas' }).props.onClick()) expect(onRequestCanvasCritique).toHaveBeenCalledWith(expect.stringContaining('current canvas')) await act(async () => renderer.unmount()) }) diff --git a/src/renderer/src/components/plan/PlanScheduledBuildDialog.test.ts b/src/renderer/src/components/plan/PlanScheduledBuildDialog.test.ts index cccf56820..46eb076c9 100644 --- a/src/renderer/src/components/plan/PlanScheduledBuildDialog.test.ts +++ b/src/renderer/src/components/plan/PlanScheduledBuildDialog.test.ts @@ -177,7 +177,7 @@ describe('PlanScheduledBuildDialog i18n', () => { expect(text).toContain('需要保持 Kun 运行。') expect(text).toContain('26小时后') expect(text).toContain('空闲时段价格减半') - expect(text).toContain('每天 09:00–12:00、14:00–18:00(北京时间)') + expect(text).toContain('高峰期:周一至周五 09:00–12:00、14:00–18:00(北京时间)') const reasoningText = renderer.root .findAllByType('select') .map((select) => dialogTextChildren(select)) diff --git a/src/renderer/src/components/sdd/SddAssistantPanel.test.ts b/src/renderer/src/components/sdd/SddAssistantPanel.test.ts index 5ebcdd46d..1639b053e 100644 --- a/src/renderer/src/components/sdd/SddAssistantPanel.test.ts +++ b/src/renderer/src/components/sdd/SddAssistantPanel.test.ts @@ -76,7 +76,7 @@ describe('SddAssistantPanel', () => { onCollapse: () => undefined })) - expect(html).toContain('aria-label="Model"') + expect(html).toContain('aria-label="Model: Codex / gpt-5.6-sol"') expect(html).toContain('aria-label="Reasoning: High"') expect(html).toContain('aria-label="Fast mode on"') }) diff --git a/src/renderer/src/components/settings-section-agents-provider-controls.test.ts b/src/renderer/src/components/settings-section-agents-provider-controls.test.ts index bbd1b6534..aee331174 100644 --- a/src/renderer/src/components/settings-section-agents-provider-controls.test.ts +++ b/src/renderer/src/components/settings-section-agents-provider-controls.test.ts @@ -328,7 +328,7 @@ describe('AgentsSettingsSection Kun diagnostics smoke', () => { expect(activePanelText(renderer)).toContain('Provider connection') expect(activePanelText(renderer)).not.toContain('Provider models') expect(renderer.root.findAllByType('select').some((select) => select.props.value === 'messages')).toBe(true) - expect(rendererText(renderer)).not.toContain('Enter provider API key') + expect(rendererText(renderer)).toContain('Enter provider API key') expect(rendererText(renderer)).not.toContain('Inherit API key') const preventDefault = vi.fn() diff --git a/src/renderer/src/components/settings-section-agents-provider-discovery.test.ts b/src/renderer/src/components/settings-section-agents-provider-discovery.test.ts index 74c770845..88afe003b 100644 --- a/src/renderer/src/components/settings-section-agents-provider-discovery.test.ts +++ b/src/renderer/src/components/settings-section-agents-provider-discovery.test.ts @@ -482,8 +482,8 @@ describe('AgentsSettingsSection Kun diagnostics smoke', () => { } }) expect(update.mock.calls.some(([patch]) => JSON.stringify(patch).includes( - `\"id\":\"${zenmux.id}\",\"name\"` - ) && JSON.stringify(patch).includes('\"useProxy\":true'))).toBe(true) + `"id":"${zenmux.id}","name"` + ) && JSON.stringify(patch).includes('"useProxy":true'))).toBe(true) }) it('saves the Provider proxy switch while retaining an inactive selection', async () => { @@ -511,8 +511,8 @@ describe('AgentsSettingsSection Kun diagnostics smoke', () => { await act(async () => toggle.props.onClick()) expect(update.mock.calls.some(([patch]) => JSON.stringify(patch).includes( - `\"id\":\"${zenmux.id}\",\"name\"` - ) && JSON.stringify(patch).includes('\"useProxy\":false'))).toBe(true) + `"id":"${zenmux.id}","name"` + ) && JSON.stringify(patch).includes('"useProxy":false'))).toBe(true) }) it('imports Cursor mixed-vendor context, vision, and SDK aliases', async () => { diff --git a/src/renderer/src/components/settings-section-agents-provider-imports.test.ts b/src/renderer/src/components/settings-section-agents-provider-imports.test.ts index f2c0e5022..c3d8ba3d8 100644 --- a/src/renderer/src/components/settings-section-agents-provider-imports.test.ts +++ b/src/renderer/src/components/settings-section-agents-provider-imports.test.ts @@ -364,9 +364,11 @@ describe('AgentsSettingsSection Kun diagnostics smoke', () => { await Promise.resolve() }) expect(probeModelProvider).toHaveBeenCalledWith({ + providerId: probeProvider.id, baseUrl: 'https://api.example.com/v1', apiKey: 'sk-probe', - endpointFormat: 'chat_completions' + endpointFormat: 'chat_completions', + useProxy: false }) expect(fetchModelsDevCatalog).not.toHaveBeenCalled() expect(rendererText(renderer)).toContain('Connected · 18ms · 2 models') @@ -421,9 +423,11 @@ describe('AgentsSettingsSection Kun diagnostics smoke', () => { }) expect(probeModelProvider).toHaveBeenCalledWith({ + providerId: target.id, baseUrl: target.baseUrl, apiKey: target.apiKey, - endpointFormat: target.endpointFormat + endpointFormat: target.endpointFormat, + useProxy: target.useProxy }) expect(fetchModelsDevCatalog).toHaveBeenCalledWith({ providerId: target.id, diff --git a/src/renderer/src/components/settings-section-agents-provider-races.test.ts b/src/renderer/src/components/settings-section-agents-provider-races.test.ts index 07dfb6592..0c9dc933b 100644 --- a/src/renderer/src/components/settings-section-agents-provider-races.test.ts +++ b/src/renderer/src/components/settings-section-agents-provider-races.test.ts @@ -292,7 +292,13 @@ describe('AgentsSettingsSection Kun diagnostics smoke', () => { const update = vi.fn() const initialCtx = { ...baseCtx(), - provider: { ...settings, providers: [...settings.providers, target] }, + provider: { + ...settings, + providers: [ + ...settings.providers.filter((provider) => provider.id !== 'opencode-free'), + target + ] + }, kun: { ...defaultKunRuntimeSettings(), providerId: target.id, diff --git a/src/renderer/src/components/settings-section-agents-provider-registry.test.ts b/src/renderer/src/components/settings-section-agents-provider-registry.test.ts index 3b06ba07a..5614ab12e 100644 --- a/src/renderer/src/components/settings-section-agents-provider-registry.test.ts +++ b/src/renderer/src/components/settings-section-agents-provider-registry.test.ts @@ -536,9 +536,11 @@ describe('AgentsSettingsSection Kun diagnostics smoke', () => { }) expect(probeModelProvider).toHaveBeenCalledWith({ + providerId: 'ollama', baseUrl: 'https://ollama.com/v1', apiKey: 'ollama-secret', - endpointFormat: 'chat_completions' + endpointFormat: 'chat_completions', + useProxy: false }) expect(fetchModelsDevCatalog).toHaveBeenCalledWith({ providerId: 'ollama', diff --git a/src/renderer/src/components/settings-section-agents-provider-subscriptions.test.ts b/src/renderer/src/components/settings-section-agents-provider-subscriptions.test.ts index ef8d96d8f..011e68da7 100644 --- a/src/renderer/src/components/settings-section-agents-provider-subscriptions.test.ts +++ b/src/renderer/src/components/settings-section-agents-provider-subscriptions.test.ts @@ -555,13 +555,13 @@ describe('AgentsSettingsSection Kun diagnostics smoke', () => { provider: { providers: expect.arrayContaining([ expect.objectContaining({ - id: 'custom-provider-3', + id: 'custom-provider-4', apiKey: '' }) ]) }, agents: { - kun: expect.objectContaining({ providerId: 'custom-provider-3' }) + kun: expect.objectContaining({ providerId: 'custom-provider-4' }) } }) expect(runtimeRequest.mock.calls.some(([path, method, body]) => diff --git a/src/renderer/src/components/settings-section-providers-connection-panels.tsx b/src/renderer/src/components/settings-section-providers-connection-panels.tsx index 3304ffbd4..a9fa2b318 100644 --- a/src/renderer/src/components/settings-section-providers-connection-panels.tsx +++ b/src/renderer/src/components/settings-section-providers-connection-panels.tsx @@ -7,7 +7,8 @@ import { MODEL_ENDPOINT_FORMATS, modelProviderSupportsAppProxy, normalizeProxyUrl, - modelProviderRequiresApiKey + modelProviderRequiresApiKey, + resolveModelProviderPresetSource } from '@shared/app-settings' import type { ModelProviderTokenPlanRegion @@ -62,8 +63,10 @@ export { sharedModelConnectionHasUsableCredential } from '../lib/provider-creden export function ProviderConnectionAdvancedPanels({ view }: { view: Record }): ReactElement { - const { t, showApiKey, selectControlClass, zh, sharedConnectionsError, credentialRevealError, activeTab, expandedCapabilities, activeProvider, activeRetry, isDraftActive, canEditActiveProviderId, patchProviderProfile, updateModelProvider, updateActiveProviderCredential, toggleActiveProviderCredentialVisibility, flushSharedProviderCredential, updateModelProviderImage, removeModelProviderImage, updateModelProviderId, activeProbe, probeNotice, activeBaseUrlInvalid, activeImageBaseUrlInvalid, activeMissingCredential, activeCursorAccount, activeCursorAccountFresh, activeCursorApiKeyUrl, activeSharedConnection, activeCredentialNeedsReplacement, activeApiKeyPlaceholder, activeApiKeyValue, activeCredentialRevealBusy, providerProxy, setGlobalNetworkOpen } = view + const { t, showApiKey, selectControlClass, zh, sharedConnectionsError, credentialRevealError, activeTab, expandedCapabilities, activeProvider, activeRetry, canEditActiveProviderId, patchProviderProfile, updateModelProvider, updateActiveProviderCredential, toggleActiveProviderCredentialVisibility, flushSharedProviderCredential, updateModelProviderImage, removeModelProviderImage, updateModelProviderId, activeProbe, probeNotice, activeBaseUrlInvalid, activeImageBaseUrlInvalid, activeMissingCredential, activeCursorAccount, activeCursorAccountFresh, activeCursorApiKeyUrl, activeSharedConnection, activeCredentialNeedsReplacement, activeApiKeyPlaceholder, activeApiKeyValue, activeCredentialRevealBusy, providerProxy, setGlobalNetworkOpen } = view const activeProviderNeedsApiKey = modelProviderRequiresApiKey(activeProvider) + const activeProviderAcceptsApiKey = activeProviderNeedsApiKey || + !resolveModelProviderPresetSource(activeProvider) const proxySupported = modelProviderSupportsAppProxy(activeProvider) const proxyUrlValid = Boolean(normalizeProxyUrl(providerProxy.url)) const proxyState = !proxySupported @@ -223,7 +226,7 @@ export function ProviderConnectionAdvancedPanels({ view }: { view: Record ) : ( <> - {activeProviderNeedsApiKey ? ( + {activeProviderAcceptsApiKey ? (