diff --git a/.gitignore b/.gitignore index c2216e6bb..630b2048b 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ TestResults.xml .workbuddy/ .kun-design/ .kun-canvas/ +.kunsdd/ openspec/ ### Internal docs diff --git a/build/installer.nsh b/build/installer.nsh index e86ac1e76..827a390f6 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -1,3 +1,13 @@ +!macro customInit + ${if} ${isUpdated} + # electron-updater always passes --updated, including older Kun versions + # that launched the assisted installer without /S. Force only that path + # into silent mode so retry/cancel dialogs use their safe default while a + # manually launched installer remains interactive. + SetSilent silent + ${endif} +!macroend + !macro customCheckAppRunning Var /GLOBAL KunInstallerCurrentPid Var /GLOBAL KunInstallerStopAttempt diff --git a/docs/kun-architecture.md b/docs/kun-architecture.md index fc77fac3c..48b13be84 100644 --- a/docs/kun-architecture.md +++ b/docs/kun-architecture.md @@ -86,8 +86,16 @@ provider 原生缓存字段;这些历史数据只能作为旧实现的证据 ## Subagent 召回与派发 -`delegate_task` 把可信的内置、GUI 配置和工作区 `.kun/agents/*.md` 目标统一成 -独立 agent profile 检索集合,不再存在 skill worker。仓库可编辑的 +`delegate_task` 是唯一创建 child run 的入口,`list_subagent_profiles` 是主代理专用的 +只读发现工具。开启“使用现有代理”时,发现结果只按页返回当前 workspace 和 product +surface 的有效 profile;`delegate_task` 只公开可选 `profile`,省略时由 Kun 在有效 +目录中自动路由。该模式不向模型公开 `custom_agent`,宿主也会拒绝旧客户端或手工请求 +携带的该字段。关闭该开关时不读取或返回注入目录,发现结果只描述一次性 custom +能力,且 `delegate_task` 必须提供 `custom_agent`。 +动态目录只出现在工具结果中,不写入稳定 system prompt 或工具 schema。 + +可信的内置、GUI 配置和工作区 `.kun/agents/*.md` 目标统一成独立 agent profile +检索集合,不再存在 skill worker。仓库可编辑的 `.kun/agents/*.md` 进入自动 BM25/LLM 召回(仅索引 id/name/description,不索引 body),也可按精确 ID 显式选择,并出现在设置页与工作台右侧子代理面板(带 「自定义」标签;定义来自 markdown,面板内只读)。未写 `toolPolicy` 时默认只读;显式 @@ -113,7 +121,7 @@ shared 读取,保持升级前的全局可用语义。 工作台可通过“扩展代理”总开关一次性启用这 37 个角色,或通过“仅保留基础代理” 清空全部扩展角色的 surface 分配。 -未显式指定 `profile` 或 `custom_agent` 时,派发顺序固定为: +在“使用现有代理”模式下未显式指定 `profile` 时,派发顺序固定为: 1. 对 ID/名称、description 和单一权威目录中的双语能力 facets 建立字段加权 BM25 索引,使用 `k1=1.2`、`b=0.75`,并按任务显式只读/修改意图做策略加权后 @@ -121,21 +129,19 @@ shared 读取,保持升级前的全局可用语义。 2. 使用 `roles.smallModel`(未配置则父会话/运行时模型)做一次无工具、JSON 约束的判断。模型只能选择 Top 5 中的 profile,且 confidence 至少为 0.60; 低于阈值或没有完整匹配时返回生成角色所需的 brief。 -3. 无合适项时由独立 `SubagentGenerator` 从最多 3 个可信内置 agent prompt 中总结 - 设计模式,生成只对本次 child run 生效的完整 profile;它不写入 settings 或 - workspace,并强制屏蔽 `delegate_task`、`generate_subagent`、`load_skill`。 -4. 判断模型超时、报错、输出非法 JSON 或虚构候选 ID 时,只有任务明确点名 - Top 1 的 ID/名称才直达该候选;普通词面重叠不足以证明适配,此时与完全无召回 - 一样进入独立生成器;失败路径默认 read-only,只有显式权限选择或一次有效的 - LLM 判断可以要求 inherit。父 abort 会直接终止派发,不会生成 fallback child。 +3. 没有有效 specialist、判断模型超时/报错/输出非法 JSON 或虚构候选 ID 时, + 复用配置的 default profile(通常是 `general`),而不是现场生成角色。父 abort + 会直接终止派发,不会启动 fallback child。 显式 `profile` 是稳定直达路径;选中的 profile 会连同来源和权限在执行前快照, -不在 recall 与 run 之间重新读取。`custom_agent` 允许主 agent 直接给出一次性角色, -`generate_subagent` 则显式要求系统自动设计并立即执行临时角色。任何路径都不能扩大 +不在 recall 与 run 之间重新读取。只有关闭“使用现有代理”后,`custom_agent` 才允许 +主 agent 直接给出一次性角色;它不写入 settings/workspace,并继承当前 turn 的 +model/provider/reasoning 选择。升级前已经持久化的 `custom:*` child record 仍可读取, +但不会让严格现有代理模式重新开放 custom 派发。任何路径都不能扩大 父 turn 的 approval policy、sandbox 根、工具/工具 Provider allowlist、denylist 或 Memory -边界;有效能力始终是父快照与 profile 约束的交集。独立 workflow agent 和生成 agent -都禁用 Skills 自动激活。child record 持久化 route method、Top 5、选择理由、置信度、 -生成样例及临时角色快照;router 与 generator 的 usage 分别计入父 thread。 +边界;有效能力始终是父快照与 profile 约束的交集。独立 workflow agent 和一次性 +custom agent 都禁用 Skills 自动激活。child record 持久化 route method、Top 5、 +选择理由、置信度及临时角色快照;router usage 计入父 thread。 下一阶段仍值得推进的缓存能力: diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index fef6983a0..c330a9ce7 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -189,6 +189,20 @@ module.exports = { from: 'resources/whisper', to: 'whisper', filter: ['**/*'] + }, + { + from: 'resources/officecli/current', + to: 'officecli', + filter: ['officecli', 'officecli.exe', 'selected.json'] + }, + { + from: 'resources/officecli/manifest.json', + to: 'officecli/manifest.json' + }, + { + from: 'resources/officecli/legal', + to: 'officecli/legal', + filter: ['LICENSE', 'NOTICE', 'THIRD-PARTY-NOTICES.txt'] } ], artifactName: `Kun-${artifactVersion}-\${os}-\${arch}.\${ext}`, diff --git a/kun/package-lock.json b/kun/package-lock.json index fcfe9c488..c26333072 100644 --- a/kun/package-lock.json +++ b/kun/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.193", + "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@computer-use/nut-js": "^4.2.0", "@cursor/sdk": "1.0.24", "@emnapi/core": "^1.10.0", @@ -26,6 +26,8 @@ "proxy-agent": "^8.0.2", "safe-regex2": "5.1.1", "semver": "^7.8.5", + "typescript": "5.9.3", + "typescript-language-server": "5.3.0", "undici": "^7.28.0", "yauzl": "^3.4.0", "yazl": "^3.3.1", @@ -39,7 +41,6 @@ "@types/semver": "^7.7.1", "@types/yauzl": "^3.4.0", "@types/yazl": "^3.3.1", - "typescript": "^5.8.2", "vitest": "^4.1.7" } }, @@ -73,22 +74,22 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.193", - "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.193.tgz", - "integrity": "sha512-WzL03VJE1sT0Nz3rEpsYMYR+9n6iyQtLVt7ghMWnYC9pvDsiy6kwcMplZniWSjH8Dm6CfkUBN5t6KB4i/JfouA==", + "version": "0.3.220", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.220.tgz", + "integrity": "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.193", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.193", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.193", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.193", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.193", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.193", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.193", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.193" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -97,9 +98,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.193", - "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.193.tgz", - "integrity": "sha512-1hT7b+KIm/3E1OSJofr7PF21Xq2zT1ccnjzuVcWQ5LYXJ09lgCMvA/ZfcDBKuoyCZ4lSnuicKXZ/h5vRbWfqrA==", + "version": "0.3.220", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.220.tgz", + "integrity": "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q==", "cpu": [ "arm64" ], @@ -110,9 +111,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.193", - "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.193.tgz", - "integrity": "sha512-9x5Y/L6iwETwEJFmPaYXfsE8q0cVgx75V7nL60HvSzi8K1XQNcG5u53jOzRvqDNah8mvGYOb+9AWrMCSJvmFyA==", + "version": "0.3.220", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.220.tgz", + "integrity": "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA==", "cpu": [ "x64" ], @@ -123,9 +124,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.193", - "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.193.tgz", - "integrity": "sha512-gvfD9pKHXWxCkkIX6bC4/FOALTxqHYjAu83iT1bzn5mCv6QWSZRl6WLRRtvKpWtWuY1jpML1lL3YfKADsAX/rA==", + "version": "0.3.220", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.220.tgz", + "integrity": "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA==", "cpu": [ "arm64" ], @@ -136,9 +137,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.193", - "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.193.tgz", - "integrity": "sha512-a3qsVTBe4G6ndsVavfIXEVAXLVXM8uvBUNYpm4jKqk2+ovWZQe/96CXOwmerg9U+/bllg4QJ9lSyYCsdtVIr6w==", + "version": "0.3.220", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.220.tgz", + "integrity": "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ==", "cpu": [ "arm64" ], @@ -149,9 +150,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.193", - "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.193.tgz", - "integrity": "sha512-1sz+7cn0iuh0ThInuAYF1jpFvLyOaZ0PZYQIF7eb9JDZbBohUf6INeTCwjAwUL0ASCq2xS7Odu/qDVuTtLTeDA==", + "version": "0.3.220", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.220.tgz", + "integrity": "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w==", "cpu": [ "x64" ], @@ -162,9 +163,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.193", - "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.193.tgz", - "integrity": "sha512-DLXlO4tlcWygz0Ft4nu6ai5KssByYt2tOeWdc4dFXKt6uBKXpbZVziUUq3ePO5zuAFyU6w7EjYLv8MMPURbAiQ==", + "version": "0.3.220", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.220.tgz", + "integrity": "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA==", "cpu": [ "x64" ], @@ -175,9 +176,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.193", - "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.193.tgz", - "integrity": "sha512-36LJKiGuKusgaPTVeh9QanL00UcaE0RcC4pgK800/0SenApbh979ndxI0XKUVfLHzlGkqlhkhT3foMdqS+zx1w==", + "version": "0.3.220", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.220.tgz", + "integrity": "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA==", "cpu": [ "arm64" ], @@ -188,9 +189,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.193", - "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.193.tgz", - "integrity": "sha512-VyyKZlWQpbD6nkUTeNvgmLvpqt1QaPQQBOC30tbEYwYsV5MC1I35Li0H7nWwngGqPSTtMvHpjpz6Eb2FSTn7/A==", + "version": "0.3.220", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.220.tgz", + "integrity": "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw==", "cpu": [ "x64" ], @@ -5663,7 +5664,6 @@ "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -5673,6 +5673,18 @@ "node": ">=14.17" } }, + "node_modules/typescript-language-server": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/typescript-language-server/-/typescript-language-server-5.3.0.tgz", + "integrity": "sha512-5puofxZHgFdAYtfNpmwCAvgtaYgg8wrUnH30m7Ze3QuguId5RNRadKASpOpyDxTyUdAF51FjhTdjntLw/EuWcQ==", + "license": "Apache-2.0", + "bin": { + "typescript-language-server": "lib/cli.mjs" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/uint8array-extras": { "version": "1.5.0", "resolved": "https://registry.npmmirror.com/uint8array-extras/-/uint8array-extras-1.5.0.tgz", diff --git a/kun/package.json b/kun/package.json index a2af75705..33ad7126e 100644 --- a/kun/package.json +++ b/kun/package.json @@ -66,7 +66,7 @@ "dev": "tsc -p tsconfig.build.json --watch" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.193", + "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@computer-use/nut-js": "^4.2.0", "@cursor/sdk": "1.0.24", "@emnapi/core": "^1.10.0", @@ -83,6 +83,8 @@ "proxy-agent": "^8.0.2", "safe-regex2": "5.1.1", "semver": "^7.8.5", + "typescript": "5.9.3", + "typescript-language-server": "5.3.0", "undici": "^7.28.0", "yauzl": "^3.4.0", "yazl": "^3.3.1", @@ -93,7 +95,6 @@ "@types/semver": "^7.7.1", "@types/yauzl": "^3.4.0", "@types/yazl": "^3.3.1", - "typescript": "^5.8.2", "vitest": "^4.1.7" }, "overrides": { diff --git a/kun/src/adapters/model/compat-message-projector.ts b/kun/src/adapters/model/compat-message-projector.ts index 40b760b4f..5eb931ee0 100644 --- a/kun/src/adapters/model/compat-message-projector.ts +++ b/kun/src/adapters/model/compat-message-projector.ts @@ -3,7 +3,11 @@ import type { ModelRequest } from '../../ports/model-client.js' import { isToolResultBridgeItem, repairModelHistoryItems } from '../../domain/model-history-repair.js' import { extractToolResultImages, toolResultTextWithoutImages } from '../../loop/tool-result-image.js' import { wrapUntrustedContent } from '../../security/untrusted-content.js' -import type { CompatChatMessage, CompatChatMessageContentPart } from './compat-request-codecs.js' +import { + COMPAT_HISTORY_CONTEXT, + type CompatChatMessage, + type CompatChatMessageContentPart +} from './compat-request-codecs.js' import { userMessageTextWithComposerContexts } from '../../domain/composer-context.js' export type CompatMessageProjectionOptions = { @@ -233,7 +237,11 @@ class CompatMessageProjector { return this.toolResultToMessage(item, supportsImages) case 'compaction': return item.replacedTokens > 0 - ? { role: 'system', content: `Conversation summary from earlier turns:\n${item.summary}` } + ? { + role: 'system', + content: `Conversation summary from earlier turns:\n${item.summary}`, + [COMPAT_HISTORY_CONTEXT]: true + } : null case 'review': return item.status === 'completed' && item.reviewText?.trim() @@ -404,6 +412,8 @@ function formatAttachmentDocument( `Name: ${document.name}`, `FilePath: ${document.localFilePath ?? 'unknown'}`, `MIME: ${document.mimeType}`, + ...(document.documentFormat ? [`Format: ${document.documentFormat}`] : []), + ...(document.sourceSha256 ? [`SourceSHA256: ${document.sourceSha256}`] : []), ...(document.pageCount ? [`Pages: ${document.pageCount}`] : []), ...(document.truncated ? ['Note: text truncated to fit the context limit'] : []), 'Content:', diff --git a/kun/src/adapters/model/compat-model-client.endpoint-format.test.ts b/kun/src/adapters/model/compat-model-client.endpoint-format.test.ts index eb00694f5..5a38ef445 100644 --- a/kun/src/adapters/model/compat-model-client.endpoint-format.test.ts +++ b/kun/src/adapters/model/compat-model-client.endpoint-format.test.ts @@ -3,6 +3,7 @@ import { CompatModelClient } from './compat-model-client.js' import type { ModelCapabilityMetadata } from '../../contracts/capabilities.js' import type { ModelEndpointFormat } from '../../contracts/model-endpoint-format.js' import type { ModelRequest, ModelStreamChunk } from '../../ports/model-client.js' +import { makeCompactionItem } from '../../domain/item.js' import { createCompatRequestCodecs, normalizeToolSpecs } from './compat-request-builder.js' // A single provider (OpenCode Go) routes some models over chat completions @@ -58,6 +59,59 @@ async function drain(iterable: AsyncIterable): Promise { + it('uses Gemini-compatible reasoning controls on the Google OpenAI endpoint', () => { + const codecs = createCompatRequestCodecs() + const expected = new Map([ + ['auto', undefined], + ['off', 'minimal'], + ['low', 'low'], + ['medium', 'medium'], + ['high', 'high'], + ['max', 'high'] + ]) + + for (const [reasoningEffort, wireEffort] of expected) { + const body = codecs.build({ + request: { ...request('gemini-3.6-flash'), reasoningEffort }, + model: 'gemini-3.6-flash', + messages: [], + tools: [], + stream: true, + endpointFormat: 'chat_completions', + baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai/', + isCodex: false, + isCodexLite: false, + codexNativeImageGeneration: false + }) + + expect(body).not.toHaveProperty('thinking') + if (wireEffort === undefined) { + expect(body).not.toHaveProperty('reasoning_effort') + } else { + expect(body.reasoning_effort).toBe(wireEffort) + } + } + }) + + it('keeps DeepSeek thinking controls scoped to the official DeepSeek host', () => { + const codecs = createCompatRequestCodecs() + const build = (baseUrl: string) => codecs.build({ + request: { ...request('custom-model'), reasoningEffort: 'off' }, + model: 'custom-model', + messages: [], + tools: [], + stream: true, + endpointFormat: 'chat_completions', + baseUrl, + isCodex: false, + isCodexLite: false, + codexNativeImageGeneration: false + }) + + expect(build('https://api.deepseek.com').thinking).toEqual({ type: 'disabled' }) + expect(build('https://openrouter.ai/api/v1')).not.toHaveProperty('thinking') + }) + it('excludes local tool provenance from every supported wire format', () => { const codecs = createCompatRequestCodecs() const tools = normalizeToolSpecs([{ @@ -86,9 +140,44 @@ describe('CompatModelClient per-model endpointFormat', () => { expect(serialized).not.toContain('providerKind') expect(serialized).not.toContain('providerId') expect(serialized).not.toContain('design-canvas') + if (endpointFormat === 'responses') { + expect(body).not.toHaveProperty('prompt_cache_key') + } } }) + it('uses stable thread-scoped prompt cache keys only for Codex Responses', () => { + const codecs = createCompatRequestCodecs() + const buildResponses = (threadId: string, isCodex: boolean, isCodexLite = false) => + codecs.build({ + request: { ...request('gpt-5.6-sol'), threadId }, + model: 'gpt-5.6-sol', + messages: [], + tools: [], + stream: true, + endpointFormat: 'responses', + baseUrl: isCodex + ? 'https://chatgpt.com/backend-api/codex' + : 'https://provider.example/v1', + isCodex, + isCodexLite, + codexNativeImageGeneration: false + }) + + const first = buildResponses('thread-a', true) + const repeated = buildResponses('thread-a', true) + const isolated = buildResponses('thread-b', true) + const lite = buildResponses('thread-a', true, true) + const compatible = buildResponses('thread-a', false) + + expect(first.prompt_cache_key).toBe('thread-a') + expect(repeated.prompt_cache_key).toBe(first.prompt_cache_key) + expect(isolated.prompt_cache_key).toBe('thread-b') + expect(isolated.prompt_cache_key).not.toBe(first.prompt_cache_key) + expect(lite.prompt_cache_key).toBe('thread-a') + expect(compatible).not.toHaveProperty('prompt_cache_key') + }) + it('routes an override model to the Anthropic Messages endpoint while others use chat completions', async () => { const calls: CapturedCall[] = [] const client = new CompatModelClient({ @@ -179,6 +268,72 @@ describe('CompatModelClient per-model endpointFormat', () => { expect(calls.every((call) => call.body.messages)).toBe(true) }) + it('keeps compacted Codex history in Responses input while preserving stable instructions', async () => { + const calls: CapturedCall[] = [] + const client = new CompatModelClient({ + baseUrl: 'https://chatgpt.com/backend-api/codex/responses', + apiKey: 'oauth-access-token', + model: 'gpt-5.3-codex-spark', + endpointFormat: 'custom_endpoint', + nonStreaming: true, + fetchImpl: (async (url: string, init: { body: string }) => { + calls.push({ url: String(url), body: JSON.parse(init.body) as Record }) + return new Response(JSON.stringify({ output_text: 'ok' }), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + }) as unknown as typeof fetch, + modelCapabilities: modelCapabilities({}) + }) + + await drain(client.stream({ + ...request('gpt-5.3-codex-spark'), + history: [makeCompactionItem({ + id: 'compaction_1', + threadId: 't1', + turnId: 'u1', + summary: 'Preserve the repository findings.', + replacedTokens: 80_000, + pinnedConstraints: [] + })] + })) + + expect(calls[0].body.instructions).toBe('You are a helpful assistant.') + expect(calls[0].body.input).toEqual([{ + role: 'system', + content: 'Conversation summary from earlier turns:\nPreserve the repository findings.' + }]) + expect(JSON.stringify(calls[0].body)).not.toContain('compat-history-context') + }) + + it('moves system-only Codex context into Responses input without duplicating it', async () => { + const calls: CapturedCall[] = [] + const client = new CompatModelClient({ + baseUrl: 'https://chatgpt.com/backend-api/codex/responses', + apiKey: 'oauth-access-token', + model: 'gpt-5.3-codex-spark', + endpointFormat: 'custom_endpoint', + nonStreaming: true, + fetchImpl: (async (url: string, init: { body: string }) => { + calls.push({ url: String(url), body: JSON.parse(init.body) as Record }) + return new Response(JSON.stringify({ output_text: 'ok' }), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + }) as unknown as typeof fetch, + modelCapabilities: modelCapabilities({}) + }) + + await drain(client.stream(request('gpt-5.3-codex-spark'))) + + expect(calls[0].body.instructions).toBe(' ') + expect(calls[0].body.input).toEqual([{ + role: 'system', + content: 'You are a helpful assistant.' + }]) + expect(JSON.stringify(calls[0].body).match(/You are a helpful assistant\./g)).toHaveLength(1) + }) + it('uses the Codex Responses Lite shape for GPT-5.6 models', async () => { const calls: Array<{ headers: Record; body: Record }> = [] const client = new CompatModelClient({ @@ -220,6 +375,7 @@ describe('CompatModelClient per-model endpointFormat', () => { model: 'gpt-5.6-sol', store: false, parallel_tool_calls: false, + prompt_cache_key: 't1', reasoning: { effort: 'xhigh', context: 'all_turns' } }) expect(calls[0].body).not.toHaveProperty('instructions') diff --git a/kun/src/adapters/model/compat-model-client.retry.test.ts b/kun/src/adapters/model/compat-model-client.retry.test.ts index 3ed4ac8f7..1c9841725 100644 --- a/kun/src/adapters/model/compat-model-client.retry.test.ts +++ b/kun/src/adapters/model/compat-model-client.retry.test.ts @@ -132,6 +132,48 @@ describe('CompatModelClient transient gateway retry', () => { }) describe('CompatModelClient refreshed credentials', () => { + it('keeps one Codex session across sequential calls and a 401 credential refresh', async () => { + const sessionIds: string[] = [] + const authorization: string[] = [] + const fetchImpl = (async (_url: string | URL | Request, init?: RequestInit) => { + const headers = new Headers(init?.headers) + sessionIds.push(headers.get('session_id') ?? '') + const value = headers.get('authorization') ?? '' + authorization.push(value) + return value === 'Bearer old-access' + ? Response.json({ error: 'expired' }, { status: 401 }) + : Response.json({ output_text: 'ok', status: 'completed' }) + }) as unknown as typeof fetch + let resolution = 0 + const oauthClient = new CompatModelClient({ + baseUrl: 'https://chatgpt.com/backend-api/codex', + apiKey: 'old-access', + model: 'gpt-5.6-sol', + endpointFormat: 'responses', + nonStreaming: true, + fetchImpl, + resolveCredentials: async (rejectedAccessToken?: string) => ({ + apiKey: rejectedAccessToken ? 'new-access' : 'old-access', + headers: { session_id: `credential-session-${++resolution}` }, + refreshable: true + }) + }) + + await drain(oauthClient.stream({ ...request(), model: 'gpt-5.6-sol' })) + await drain(oauthClient.stream({ ...request(), model: 'gpt-5.6-sol', turnId: 'u2' })) + + expect(authorization).toEqual([ + 'Bearer old-access', + 'Bearer new-access', + 'Bearer old-access', + 'Bearer new-access' + ]) + expect(sessionIds).toHaveLength(4) + expect(sessionIds[0]).not.toBe('') + expect(new Set(sessionIds)).toEqual(new Set([sessionIds[0]])) + expect(sessionIds[0]).not.toMatch(/^credential-session-/) + }) + it('refreshes a rejected OAuth bearer once and retries the request with the new token', async () => { const authorization: string[] = [] const fetchImpl = (async (_url: string | URL | Request, init?: RequestInit) => { diff --git a/kun/src/adapters/model/compat-model-client.ts b/kun/src/adapters/model/compat-model-client.ts index c9af49064..b13760847 100644 --- a/kun/src/adapters/model/compat-model-client.ts +++ b/kun/src/adapters/model/compat-model-client.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto' import type { ModelClient, ModelRequest, ModelStreamChunk } from '../../ports/model-client.js' import type { UsageSnapshot } from '../../contracts/usage.js' import type { ModelCapabilityMetadata } from '../../contracts/capabilities.js' @@ -146,6 +147,10 @@ type StreamPayloadResult = { const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 45_000 +function isCodexEndpoint(baseUrl: string): boolean { + return baseUrl.includes('chatgpt.com/backend-api/codex') +} + /** * Multi-provider HTTP model client. * @@ -162,11 +167,15 @@ export class CompatModelClient implements ModelClient { private readonly config: CompatModelClientConfig private readonly fetchImpl: typeof fetch + private readonly codexSessionId: string | undefined constructor(config: CompatModelClientConfig) { this.config = config this.model = config.model this.fetchImpl = config.fetchImpl ?? createProxyFetch(config.modelProxyUrl ?? '') ?? fetch + this.codexSessionId = isCodexEndpoint(config.baseUrl) + ? config.headers?.session_id?.trim() || randomUUID() + : undefined } /** @@ -251,7 +260,7 @@ export class CompatModelClient implements ModelClient { } return } - const responsesLite = this.config.baseUrl.includes('chatgpt.com/backend-api/codex') && + const responsesLite = isCodexEndpoint(this.config.baseUrl) && this.capabilitiesForModel(requestModel).responsesMode === 'lite' let headers = this.buildHeaders(stream, endpointFormat, responsesLite, credentials) const retry = normalizeModelRequestRetryConfig(this.config.retry) @@ -561,12 +570,17 @@ export class CompatModelClient implements ModelClient { headers: this.config.headers } ): Record { + const configuredHeaders = { + ...(this.config.headers ?? {}), + ...(credentials.headers ?? {}) + } + // Protected credentials are resolved before every request and may + // materialize a fresh session_id. Keep transport identity owned by this + // client so credential refresh cannot invalidate Codex prompt routing. + if (this.codexSessionId) configuredHeaders.session_id = this.codexSessionId return buildCompatRequestHeaders({ apiKey: credentials.apiKey, - configuredHeaders: { - ...(this.config.headers ?? {}), - ...(credentials.headers ?? {}) - }, + configuredHeaders, stream, endpointFormat, responsesLite @@ -615,7 +629,7 @@ export class CompatModelClient implements ModelClient { const endpointFormat = options.endpointFormat ?? this.endpointFormat() const tools = normalizeToolSpecs(request.tools) const reasoning = this.modelReasoningFor(model) - const isCodex = this.config.baseUrl.includes('chatgpt.com/backend-api/codex') + const isCodex = isCodexEndpoint(this.config.baseUrl) const isCodexLite = isCodex && this.capabilitiesForModel(model).responsesMode === 'lite' const codecs = createCompatRequestCodecs() return codecs.build({ diff --git a/kun/src/adapters/model/compat-request-builder.ts b/kun/src/adapters/model/compat-request-builder.ts index f0b950450..2b2e984a5 100644 --- a/kun/src/adapters/model/compat-request-builder.ts +++ b/kun/src/adapters/model/compat-request-builder.ts @@ -396,6 +396,7 @@ function applyReasoningEffort( options: { includeThinking?: boolean nativeDeepSeekHost?: boolean + geminiOpenAiHost?: boolean reasoning?: ModelReasoningCapability maxReasoningEffort?: 'high' | 'max' } = {} @@ -409,13 +410,17 @@ function applyReasoningEffort( // Third-party OpenAI-compat proxies (SiliconFlow, OpenRouter, llama.cpp, etc.) may // reject or mishandle it, causing 400 errors or empty responses. See issue #26. const nativeDeepSeek = options.nativeDeepSeekHost === true + if (options.geminiOpenAiHost === true) { + applyGeminiOpenAiReasoningEffort(body, normalized) + return + } if (options.reasoning) { applyProfileReasoningEffort(body, normalized, options.reasoning, includeThinking, nativeDeepSeek) return } switch (normalized) { case 'off': - if (includeThinking) body.thinking = { type: 'disabled' } + if (nativeDeepSeek) body.thinking = { type: 'disabled' } break case 'low': case 'medium': @@ -430,6 +435,29 @@ function applyReasoningEffort( } } +function applyGeminiOpenAiReasoningEffort( + body: Record, + effort: NormalizedReasoningEffort +): void { + switch (effort) { + case 'auto': + return + case 'off': + // Gemini 3 models cannot disable thinking. "minimal" is the closest + // compatible setting and is also accepted by Gemini 2.5. + body.reasoning_effort = 'minimal' + return + case 'low': + case 'medium': + case 'high': + body.reasoning_effort = effort + return + case 'max': + body.reasoning_effort = 'high' + return + } +} + function applyProfileReasoningEffort( body: Record, effort: NormalizedReasoningEffort, diff --git a/kun/src/adapters/model/compat-request-codecs.ts b/kun/src/adapters/model/compat-request-codecs.ts index dfa2c4760..910110e86 100644 --- a/kun/src/adapters/model/compat-request-codecs.ts +++ b/kun/src/adapters/model/compat-request-codecs.ts @@ -1,11 +1,14 @@ import type { ModelCapabilityMetadata } from '../../contracts/capabilities.js' import type { ModelEndpointFormat } from '../../contracts/model-endpoint-format.js' import type { ModelRequest, ModelToolSpec } from '../../ports/model-client.js' -import { isDeepSeekHost } from './model-error-probe.js' +import { isDeepSeekHost, isGeminiOpenAiHost } from './model-error-probe.js' + +export const COMPAT_HISTORY_CONTEXT = Symbol('compat-history-context') export type CompatChatMessage = { role: 'system' | 'user' | 'assistant' | 'tool' content: string | CompatChatMessageContentPart[] | null + [COMPAT_HISTORY_CONTEXT]?: true name?: string tool_call_id?: string reasoning_content?: string @@ -50,7 +53,12 @@ export type CompatRequestCodecDeps = { applyChatReasoning: ( body: Record, effort: string | undefined, - input: { includeThinking: boolean; nativeDeepSeekHost: boolean; reasoning?: ReasoningCapability } + input: { + includeThinking: boolean + nativeDeepSeekHost: boolean + geminiOpenAiHost: boolean + reasoning?: ReasoningCapability + } ) => void responsesReasoning: ( effort: string | undefined, @@ -97,10 +105,12 @@ export class CompatRequestCodecs { if (input.request.responseFormat === 'json_object') body.response_format = { type: 'json_object' } if (input.stream && input.includeStreamUsage !== false) body.stream_options = { include_usage: true } const nativeDeepSeekHost = isDeepSeekHost(input.baseUrl) - const includeThinking = !isAzureOpenAiEndpoint(input.baseUrl) + const geminiOpenAiHost = isGeminiOpenAiHost(input.baseUrl) + const includeThinking = !isAzureOpenAiEndpoint(input.baseUrl) && !geminiOpenAiHost this.deps.applyChatReasoning(body, input.request.reasoningEffort, { includeThinking, nativeDeepSeekHost, + geminiOpenAiHost, reasoning: input.reasoning }) if ( @@ -124,18 +134,31 @@ export class CompatRequestCodecs { } private responses(input: CompatRequestCodecInput): Record { - const system = input.isCodex ? input.messages.filter((message) => message.role === 'system') : [] + const system = input.isCodex + ? input.messages.filter( + (message) => message.role === 'system' && message[COMPAT_HISTORY_CONTEXT] !== true + ) + : [] const nonSystem = input.isCodex - ? input.messages.filter((message) => message.role !== 'system') + ? input.messages.filter( + (message) => message.role !== 'system' || message[COMPAT_HISTORY_CONTEXT] === true + ) : input.messages - const instructions = system + let instructions = system .map((message) => this.deps.plainText(message.content).trim()) .filter(Boolean) .join('\n\n') const responseTools = input.tools.map((tool) => ({ type: 'function', name: tool.name, description: tool.description, parameters: tool.inputSchema })) - const responseInput = this.deps.responsesInput(this.deps.splitOpenAiMessages(nonSystem)) + let responseInput = this.deps.responsesInput(this.deps.splitOpenAiMessages(nonSystem)) + if (input.isCodex && !input.isCodexLite && responseInput.length === 0 && instructions) { + // The Responses endpoint requires input even when the request has only + // system context. Move (rather than duplicate) that context into a + // supported system-role input item so the wire request remains valid. + responseInput = [{ role: 'system', content: instructions }] + instructions = '' + } const litePrefix: Array> = input.isCodexLite ? [ { type: 'additional_tools', role: 'developer', tools: responseTools }, @@ -151,7 +174,8 @@ export class CompatRequestCodecs { input: input.isCodexLite ? [...litePrefix, ...responseInput] : responseInput, ...(input.isCodexLite ? { store: false, tool_choice: 'auto', parallel_tool_calls: false } - : input.isCodex ? { instructions: instructions || ' ', store: false } : {}) + : input.isCodex ? { instructions: instructions || ' ', store: false } : {}), + ...(input.isCodex ? { prompt_cache_key: input.request.threadId } : {}) } if (input.maxTokens !== undefined && !input.isCodex) body.max_output_tokens = input.maxTokens if (input.request.temperature !== undefined) body.temperature = input.request.temperature diff --git a/kun/src/adapters/model/gemini-cli-api-model-client.test.ts b/kun/src/adapters/model/gemini-cli-api-model-client.test.ts new file mode 100644 index 000000000..646695797 --- /dev/null +++ b/kun/src/adapters/model/gemini-cli-api-model-client.test.ts @@ -0,0 +1,389 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ModelRequest, ModelStreamChunk } from '../../ports/model-client.js' +import { makeToolCallItem, makeToolResultItem } from '../../domain/item.js' +import { LlmDebugRecorder } from '../../services/llm-debug-recorder.js' +import { GeminiCliOAuthSource } from './gemini-cli-oauth.js' +import { + buildGeminiCliCodeAssistRequest, + GeminiCliApiModelClient +} from './gemini-cli-api-model-client.js' + +function request(overrides: Partial = {}): ModelRequest { + return { + threadId: 'thread-gemini', + turnId: 'turn-gemini', + providerId: 'gemini-cli-subscription', + model: 'gemini-2.5-flash', + systemPrompt: 'You are Kun.', + prefix: [], + history: [{ + id: 'user-1', + turnId: 'turn-gemini', + threadId: 'thread-gemini', + role: 'user', + status: 'completed', + createdAt: '2026-01-01T00:00:00.000Z', + kind: 'user_message', + text: 'Say hello.' + }], + tools: [], + abortSignal: new AbortController().signal, + ...overrides + } +} + +async function drain(iterable: AsyncIterable): Promise { + const chunks: ModelStreamChunk[] = [] + for await (const chunk of iterable) chunks.push(chunk) + return chunks +} + +function oauth(fetchImpl: typeof fetch): GeminiCliOAuthSource { + return new GeminiCliOAuthSource({ + fetchImpl, + now: () => 1_000, + loadCredential: async () => ({ + accessToken: 'official-access-token', + refreshToken: 'official-refresh-token', + expiresAt: 100_000 + }) + }) +} + +describe('GeminiCliApiModelClient', () => { + it('streams direct Code Assist text, reasoning, tools, usage, and provider metadata', async () => { + const requests: Array<{ + url: string + body: Record + authorization: string + headers: Record + }> = [] + const stream = [ + 'data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"thinking","thought":true},{"text":"hello "},{"functionCall":{"id":"provider-call","name":"read","args":{"path":"a.ts"}},"thoughtSignature":"signature-bytes"}]}}],"usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":4,"thoughtsTokenCount":2,"totalTokenCount":26,"cachedContentTokenCount":15}}}\n\n', + 'data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"world"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":5,"thoughtsTokenCount":2,"totalTokenCount":27,"cachedContentTokenCount":15}}}\n\n' + ].join('') + const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as Record + const headers = new Headers(init?.headers) + requests.push({ + url: String(url), + body, + authorization: headers.get('authorization') ?? '', + headers: Object.fromEntries(headers.entries()) + }) + if (String(url).endsWith(':loadCodeAssist')) { + return new Response(JSON.stringify({ + currentTier: { id: 'standard-tier' }, + paidTier: { id: 'g1-pro-tier' }, + cloudaicompanionProject: 'managed-project' + }), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + } + return new Response(stream, { + status: 200, + headers: { 'content-type': 'text/event-stream' } + }) + }) as unknown as typeof fetch + const client = new GeminiCliApiModelClient({ + model: 'gemini-2.5-flash', + fetchImpl, + oauthSource: oauth(fetchImpl) + }) + + const chunks = await drain(client.stream(request({ + tools: [{ + name: 'read', + description: 'Read a file', + inputSchema: { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'] + } + }], + reasoningEffort: 'medium', + maxTokens: 256 + }))) + + expect(chunks).toContainEqual({ kind: 'assistant_reasoning_delta', text: 'thinking' }) + expect(chunks).toContainEqual({ kind: 'assistant_text_delta', text: 'hello ' }) + expect(chunks).toContainEqual({ kind: 'assistant_text_delta', text: 'world' }) + expect(chunks).toContainEqual({ + kind: 'tool_call_complete', + callId: 'provider-call', + toolName: 'read', + arguments: { path: 'a.ts' }, + providerMetadata: { gemini: { thoughtSignature: 'signature-bytes' } } + }) + expect(chunks).toContainEqual({ + kind: 'usage', + usage: expect.objectContaining({ + promptTokens: 20, + completionTokens: 5, + reasoningTokens: 2, + totalTokens: 27, + cacheHitTokens: 15, + cacheMissTokens: 5, + actualProviderId: 'gemini-cli-subscription', + actualModelId: 'gemini-2.5-flash' + }) + }) + expect(chunks.at(-1)).toEqual({ kind: 'completed', stopReason: 'tool_calls' }) + expect(requests).toHaveLength(2) + expect(requests[1]?.authorization).toBe('Bearer official-access-token') + expect(requests[1]?.headers['user-agent']).toBe('google-gemini-cli') + expect(requests[1]?.headers['x-goog-api-client']).toBe('gl-node/kun gemini-cli-api') + expect(requests[1]?.url).toContain(':streamGenerateContent?alt=sse') + expect(requests[1]?.body).toMatchObject({ + model: 'gemini-2.5-flash', + project: 'managed-project', + request: { + tools: [{ + functionDeclarations: [{ + name: 'read', + parametersJsonSchema: expect.objectContaining({ type: 'object' }) + }] + }], + generationConfig: { + maxOutputTokens: 256, + thinkingConfig: { thinkingBudget: 8_192, includeThoughts: true } + } + } + }) + }) + + it('replays thought signatures only in Gemini requests and redacts them from traces', async () => { + const signature = 'opaque-thought-signature' + const toolCall = makeToolCallItem({ + id: 'tool-call', + turnId: 'turn-old', + threadId: 'thread-gemini', + callId: 'call-old', + toolName: 'read', + arguments: { path: 'old.ts' }, + providerMetadata: { gemini: { thoughtSignature: signature } }, + status: 'completed' + }) + const toolResult = makeToolResultItem({ + id: 'tool-result', + turnId: 'turn-old', + threadId: 'thread-gemini', + callId: 'call-old', + toolName: 'read', + output: 'old contents', + status: 'completed' + }) + const input = request({ history: [toolCall, toolResult] }) + const built = buildGeminiCliCodeAssistRequest(input, input.model, 'project') + expect(JSON.stringify(built)).toContain(signature) + + let transmittedBody = '' + const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).endsWith(':loadCodeAssist')) { + return new Response(JSON.stringify({ + currentTier: { id: 'standard-tier' }, + cloudaicompanionProject: 'project' + }), { status: 200, headers: { 'content-type': 'application/json' } }) + } + transmittedBody = String(init?.body) + return new Response( + 'data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"done"}]},"finishReason":"STOP"]}}\n\n', + { status: 200, headers: { 'content-type': 'text/event-stream' } } + ) + }) as unknown as typeof fetch + const recorder = new LlmDebugRecorder() + const client = new GeminiCliApiModelClient({ + model: input.model, + fetchImpl, + oauthSource: oauth(fetchImpl), + debugSink: recorder + }) + + await drain(client.stream(input)) + const trace = (await recorder.listThread(input.threadId)).records[0] + expect(transmittedBody).toContain(signature) + expect(trace?.request.body.text).not.toContain(signature) + expect(trace?.request.body.text).toContain('[REDACTED]') + expect(trace?.request.headers.values.authorization).not.toContain('official-access-token') + }) + + it('returns a conversation-safe login error instead of falling back providers', async () => { + const client = new GeminiCliApiModelClient({ + model: 'gemini-2.5-flash', + fetchImpl: vi.fn() as unknown as typeof fetch, + oauthSource: new GeminiCliOAuthSource({ + loadCredential: async () => null + }) + }) + + expect(await drain(client.stream(request()))).toEqual([{ + kind: 'error', + code: 'gemini_cli_login_required', + message: expect.stringContaining('Run `gemini`') + }]) + }) + + it('classifies unavailable account models for inline conversation errors', async () => { + const fetchImpl = vi.fn(async (url: string | URL | Request) => { + if (String(url).endsWith(':loadCodeAssist')) { + return new Response(JSON.stringify({ + currentTier: { id: 'standard-tier' }, + cloudaicompanionProject: 'project' + }), { status: 200, headers: { 'content-type': 'application/json' } }) + } + return new Response(JSON.stringify({ + error: { + code: 404, + status: 'NOT_FOUND', + message: 'Requested model is unavailable for this account.' + } + }), { + status: 404, + headers: { + 'content-type': 'application/json', + 'retry-after': '30' + } + }) + }) as unknown as typeof fetch + const client = new GeminiCliApiModelClient({ + model: 'gemini-3.1-pro-preview', + fetchImpl, + oauthSource: oauth(fetchImpl) + }) + + expect(await drain(client.stream(request({ + model: 'gemini-3.1-pro-preview' + })))).toEqual([{ + kind: 'error', + code: 'gemini_cli_api_request_failed', + message: expect.stringContaining('NOT_FOUND'), + failure: { + category: 'model_not_found', + httpStatus: 404, + providerCode: 'NOT_FOUND', + retryAfterMs: 30_000, + failoverAllowed: true + } + }]) + }) + + it('turns a provider success with no visible content into an inline error', async () => { + const fetchImpl = vi.fn(async (url: string | URL | Request) => { + if (String(url).endsWith(':loadCodeAssist')) { + return new Response(JSON.stringify({ + currentTier: { id: 'standard-tier' }, + cloudaicompanionProject: 'project' + }), { status: 200, headers: { 'content-type': 'application/json' } }) + } + return new Response( + 'data: {"response":{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"MAX_TOKENS"}]}}\n\n', + { status: 200, headers: { 'content-type': 'text/event-stream' } } + ) + }) as unknown as typeof fetch + const client = new GeminiCliApiModelClient({ + model: 'gemini-2.5-flash', + fetchImpl, + oauthSource: oauth(fetchImpl) + }) + + expect(await drain(client.stream(request()))).toEqual([{ + kind: 'error', + code: 'gemini_cli_api_empty_response', + message: expect.stringContaining('output-token budget'), + failure: { + category: 'unavailable', + failoverAllowed: true + } + }]) + }) + + it('retries transient Code Assist capacity failures before completing a tool round', async () => { + let streamAttempts = 0 + const fetchImpl = vi.fn(async (url: string | URL | Request) => { + if (String(url).endsWith(':loadCodeAssist')) { + return new Response(JSON.stringify({ + currentTier: { id: 'standard-tier' }, + cloudaicompanionProject: 'project' + }), { status: 200, headers: { 'content-type': 'application/json' } }) + } + streamAttempts += 1 + if (streamAttempts === 1) { + return new Response(JSON.stringify({ + error: { + code: 429, + status: 'RESOURCE_EXHAUSTED', + message: 'You have exhausted your capacity. Your quota will reset after 0s.' + } + }), { + status: 429, + headers: { + 'content-type': 'application/json', + 'retry-after': '0' + } + }) + } + return new Response( + 'data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"recovered"}]},"finishReason":"STOP"}]}}\n\n', + { status: 200, headers: { 'content-type': 'text/event-stream' } } + ) + }) as unknown as typeof fetch + const client = new GeminiCliApiModelClient({ + model: 'gemini-2.5-flash', + fetchImpl, + oauthSource: oauth(fetchImpl), + retry: { + maxAttempts: 1, + initialDelayMs: 0, + httpStatusCodes: [429] + } + }) + + expect(await drain(client.stream(request()))).toEqual([ + { + kind: 'retrying', + status: 429, + attempt: 1, + maxAttempts: 1, + delayMs: 0 + }, + { kind: 'assistant_text_delta', text: 'recovered' }, + { kind: 'completed', stopReason: 'stop' } + ]) + expect(streamAttempts).toBe(2) + }) + + it('parses Google quota reset durations into failure metadata', async () => { + const fetchImpl = vi.fn(async (url: string | URL | Request) => { + if (String(url).endsWith(':loadCodeAssist')) { + return new Response(JSON.stringify({ + currentTier: { id: 'standard-tier' }, + cloudaicompanionProject: 'project' + }), { status: 200, headers: { 'content-type': 'application/json' } }) + } + return new Response(JSON.stringify({ + error: { + code: 429, + status: 'RESOURCE_EXHAUSTED', + message: 'You have exhausted your capacity. Your quota will reset after 42s.' + } + }), { status: 429, headers: { 'content-type': 'application/json' } }) + }) as unknown as typeof fetch + const client = new GeminiCliApiModelClient({ + model: 'gemini-2.5-flash', + fetchImpl, + oauthSource: oauth(fetchImpl) + }) + + expect(await drain(client.stream(request()))).toEqual([expect.objectContaining({ + kind: 'error', + code: 'rate_limit_exceeded', + failure: expect.objectContaining({ + category: 'rate_limit', + httpStatus: 429, + providerCode: 'RESOURCE_EXHAUSTED', + retryAfterMs: 42_000 + }) + })]) + }) +}) diff --git a/kun/src/adapters/model/gemini-cli-api-model-client.ts b/kun/src/adapters/model/gemini-cli-api-model-client.ts new file mode 100644 index 000000000..10e4d5038 --- /dev/null +++ b/kun/src/adapters/model/gemini-cli-api-model-client.ts @@ -0,0 +1,900 @@ +import { randomUUID } from 'node:crypto' +import type { ToolCallProviderMetadata } from '../../contracts/items.js' +import type { UsageSnapshot } from '../../contracts/usage.js' +import type { ModelClient, ModelRequest, ModelStreamChunk } from '../../ports/model-client.js' +import type { + LlmDebugRound, + LlmDebugSink +} from '../../services/llm-debug-recorder.js' +import type { + CompatChatMessage, + CompatChatMessageContentPart +} from './compat-request-codecs.js' +import { projectCompatMessages } from './compat-message-projector.js' +import { createProxyFetch } from './proxy-fetch.js' +import { IncrementalSseFrameBuffer } from './incremental-sse-frame-buffer.js' +import { GeminiCliOAuthSource } from './gemini-cli-oauth.js' +import { + normalizeModelRequestRetryConfig, + parseRetryAfterMs, + retryDelayMs, + sleepWithAbort +} from './compat-retry-policy.js' +import type { ModelRequestRetryConfig } from '../../config/kun-config.js' +export const GEMINI_CLI_CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' +export const GEMINI_CLI_CODE_ASSIST_API_VERSION = 'v1internal' + +const MAX_ERROR_BODY_BYTES = 256 * 1024 +const MAX_STREAM_BYTES = 32 * 1024 * 1024 +const MAX_SSE_FRAME_BYTES = 8 * 1024 * 1024 + +export type GeminiCliApiModelClientConfig = { + model: string + modelProxyUrl?: string + endpoint?: string + apiVersion?: string + fetchImpl?: typeof fetch + oauthSource?: GeminiCliOAuthSource + debugSink?: LlmDebugSink + retry?: ModelRequestRetryConfig +} + +type GeminiPart = { + text?: string + thought?: boolean + thoughtSignature?: string + inlineData?: { mimeType?: string; data?: string } + functionCall?: { + id?: string + name?: string + args?: Record + } + functionResponse?: { + id?: string + name?: string + response?: Record + } +} + +type GeminiContent = { + role: 'user' | 'model' + parts: GeminiPart[] +} + +type GeminiCodeAssistResponse = { + response?: { + candidates?: Array<{ + content?: { role?: string; parts?: GeminiPart[] } + finishReason?: string + }> + promptFeedback?: { + blockReason?: string + blockReasonMessage?: string + } + usageMetadata?: GeminiUsageMetadata + } + error?: { + code?: number + status?: string + message?: string + } +} + +type GeminiUsageMetadata = { + promptTokenCount?: number + candidatesTokenCount?: number + totalTokenCount?: number + cachedContentTokenCount?: number + thoughtsTokenCount?: number +} + +type GeminiCodeAssistSetup = { + currentTier?: { id?: string } + paidTier?: { id?: string } + cloudaicompanionProject?: string + ineligibleTiers?: Array<{ reasonMessage?: string }> + error?: { code?: number; status?: string; message?: string } +} + +/** + * Native Kun model client for the official Gemini CLI's Google subscription + * API path. Unlike Antigravity it does not delegate the whole turn: Kun keeps + * history, tools, approvals, compaction, retries, and SSE ownership. + */ +export class GeminiCliApiModelClient implements ModelClient { + readonly provider = 'gemini-cli-api' + readonly model: string + readonly config: { + baseUrl: string + endpointFormat: 'custom_endpoint' + } + + private readonly fetchImpl: typeof fetch + private readonly oauthSource: GeminiCliOAuthSource + private readonly debugSink?: LlmDebugSink + private readonly endpoint: string + private readonly apiVersion: string + private readonly retry: ReturnType + private projectId: string | undefined + + constructor(config: GeminiCliApiModelClientConfig) { + this.model = config.model + this.endpoint = (config.endpoint ?? + process.env.CODE_ASSIST_ENDPOINT?.trim() ?? + GEMINI_CLI_CODE_ASSIST_ENDPOINT).replace(/\/+$/, '') + this.apiVersion = (config.apiVersion ?? + process.env.CODE_ASSIST_API_VERSION?.trim() ?? + GEMINI_CLI_CODE_ASSIST_API_VERSION).replace(/^\/+|\/+$/g, '') + this.config = { + baseUrl: `${this.endpoint}/${this.apiVersion}`, + endpointFormat: 'custom_endpoint' + } + this.fetchImpl = config.fetchImpl ?? + createProxyFetch(config.modelProxyUrl ?? '') ?? + fetch + this.oauthSource = config.oauthSource ?? new GeminiCliOAuthSource({ + fetchImpl: this.fetchImpl + }) + this.debugSink = config.debugSink + this.retry = normalizeModelRequestRetryConfig(config.retry) + } + + async *stream(request: ModelRequest): AsyncIterable { + const round = this.startDebugRound(request) + try { + for await (const chunk of this.streamInner(request, round)) { + safeDebug(() => this.debugSink?.captureChunk(round!, chunk)) + yield chunk + } + } finally { + if (round && this.debugSink) { + await this.debugSink.finish(round).catch(() => {}) + } + } + } + + private async *streamInner( + request: ModelRequest, + round: LlmDebugRound | null + ): AsyncIterable { + if (request.abortSignal.aborted) { + yield { kind: 'error', code: 'request_aborted', message: 'request was aborted before start' } + return + } + + let accessToken: string + try { + accessToken = await this.oauthSource.accessToken() + } catch (error) { + yield { + kind: 'error', + code: 'gemini_cli_login_required', + message: safeErrorMessage(error) + } + return + } + + try { + this.projectId = this.projectId ?? await this.loadProject(accessToken, request.abortSignal) + } catch (error) { + if (isUnauthorized(error)) { + try { + accessToken = await this.oauthSource.accessToken(accessToken) + this.projectId = await this.loadProject(accessToken, request.abortSignal) + } catch (retryError) { + yield { + kind: 'error', + code: 'gemini_cli_auth_failed', + message: safeErrorMessage(retryError) + } + return + } + } else { + yield { + kind: 'error', + code: 'gemini_cli_setup_failed', + message: safeErrorMessage(error) + } + return + } + } + + const model = request.model?.trim() || this.model + const body = buildGeminiCliCodeAssistRequest(request, model, this.projectId) + let attemptOrdinal = 0 + const post = ( + reason: 'initial' | 'credential_refresh' | 'transport_retry' + ) => this.postStream({ + body, + accessToken, + signal: request.abortSignal, + round, + attempt: ++attemptOrdinal, + reason + }) + let result = await post('initial') + let credentialRefreshAttempted = false + let transportRetryAttempt = 0 + const retryStatuses = new Set(this.retry.httpStatusCodes) + while (result.response && !result.response.ok) { + if (result.response.status === 401 && !credentialRefreshAttempted) { + credentialRefreshAttempted = true + await result.response.body?.cancel().catch(() => {}) + try { + accessToken = await this.oauthSource.accessToken(accessToken) + } catch (error) { + yield { + kind: 'error', + code: 'gemini_cli_auth_failed', + message: safeErrorMessage(error) + } + return + } + result = await post('credential_refresh') + continue + } + if ( + transportRetryAttempt >= this.retry.maxAttempts || + !retryStatuses.has(result.response.status) + ) break + const status = result.response.status + const delayMs = await geminiRetryDelayMs( + result.response, + this.retry.initialDelayMs, + transportRetryAttempt + ) + await result.response.body?.cancel().catch(() => {}) + yield { + kind: 'retrying', + status, + attempt: transportRetryAttempt + 1, + maxAttempts: this.retry.maxAttempts, + delayMs + } + const aborted = await sleepWithAbort(delayMs, request.abortSignal) + if (aborted || request.abortSignal.aborted) { + yield { + kind: 'error', + code: 'request_aborted', + message: 'Gemini CLI API request was aborted during retry backoff.' + } + return + } + transportRetryAttempt += 1 + result = await post('transport_retry') + } + if (result.error) { + yield { + kind: 'error', + code: request.abortSignal.aborted ? 'request_aborted' : 'gemini_cli_api_network_error', + message: result.error + } + return + } + const response = result.response! + if (!response.ok) { + const error = await readGeminiError(response) + yield { + kind: 'error', + code: geminiErrorCode(response.status, error.status), + message: error.message, + failure: { + category: response.status === 401 || response.status === 403 + ? 'authentication' + : response.status === 404 + ? 'model_not_found' + : response.status === 429 + ? 'rate_limit' + : response.status >= 500 + ? 'unavailable' + : 'request', + httpStatus: response.status, + ...(error.status ? { providerCode: error.status } : {}), + ...(error.retryAfterMs !== undefined + ? { retryAfterMs: error.retryAfterMs } + : {}), + failoverAllowed: + response.status === 401 || + response.status === 403 || + response.status === 404 || + response.status === 429 || + response.status >= 500 + } + } + return + } + if (!response.body) { + yield { + kind: 'error', + code: 'gemini_cli_api_empty_response', + message: 'Gemini CLI API returned no response body.' + } + return + } + + let sawToolCall = false + let sawContent = false + let finishReason = '' + let latestUsage: GeminiUsageMetadata | undefined + try { + for await (const payload of readGeminiSse(response.body, request.abortSignal)) { + const candidate = payload.response?.candidates?.[0] + finishReason = candidate?.finishReason ?? finishReason + latestUsage = payload.response?.usageMetadata ?? latestUsage + for (const part of candidate?.content?.parts ?? []) { + if (typeof part.text === 'string' && part.text) { + sawContent = true + yield part.thought + ? { kind: 'assistant_reasoning_delta', text: part.text } + : { kind: 'assistant_text_delta', text: part.text } + } + if (part.functionCall?.name) { + sawContent = true + sawToolCall = true + const providerMetadata = geminiProviderMetadata(part.thoughtSignature) + yield { + kind: 'tool_call_complete', + callId: part.functionCall.id?.trim() || randomUUID(), + toolName: part.functionCall.name, + arguments: objectValue(part.functionCall.args), + ...(providerMetadata ? { providerMetadata } : {}) + } + } + if (part.inlineData?.data && part.inlineData.mimeType?.startsWith('image/')) { + sawContent = true + yield { + kind: 'image_generation_complete', + imageBase64: part.inlineData.data, + mimeType: part.inlineData.mimeType + } + } + } + const blockReason = payload.response?.promptFeedback?.blockReason + if (blockReason && !sawContent) { + throw new Error( + `Gemini CLI API blocked the request: ${ + payload.response?.promptFeedback?.blockReasonMessage || blockReason + }` + ) + } + } + } catch (error) { + yield { + kind: 'error', + code: request.abortSignal.aborted ? 'request_aborted' : 'gemini_cli_api_stream_failed', + message: safeErrorMessage(error) + } + return + } + + if (latestUsage) { + yield { + kind: 'usage', + usage: normalizeGeminiUsage( + latestUsage, + request.providerId?.trim() || this.provider, + model + ) + } + } + if (!sawContent) { + yield { + kind: 'error', + code: 'gemini_cli_api_empty_response', + message: /MAX_TOKENS/i.test(finishReason) + ? 'Gemini CLI API exhausted the output-token budget before returning visible content.' + : 'Gemini CLI API completed without returning text, reasoning, a tool call, or an image.', + failure: { + category: 'unavailable', + failoverAllowed: true + } + } + return + } + yield { + kind: 'completed', + stopReason: sawToolCall + ? 'tool_calls' + : /MAX_TOKENS/i.test(finishReason) + ? 'length' + : 'stop' + } + } + + private startDebugRound(request: ModelRequest): LlmDebugRound | null { + if (!this.debugSink) return null + return safeDebug(() => this.debugSink!.start({ + threadId: request.threadId, + turnId: request.turnId, + provider: this.provider, + model: request.model?.trim() || this.model, + toolCatalog: request.tools.map((tool) => ({ + name: tool.name, + ...(tool.providerKind ? { providerKind: tool.providerKind } : {}), + ...(tool.providerId ? { providerId: tool.providerId } : {}) + })) + })) ?? null + } + + private async loadProject(accessToken: string, signal: AbortSignal): Promise { + const response = await this.fetchImpl(this.methodUrl('loadCodeAssist'), { + method: 'POST', + headers: geminiHeaders(accessToken), + body: JSON.stringify({ + metadata: { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI' + } + }), + signal + }) + const payload = await response.json().catch(() => null) as GeminiCodeAssistSetup | null + if (!response.ok) { + throw new GeminiCliApiHttpError( + response.status, + providerErrorMessage(payload?.error, response.status) + ) + } + const projectId = payload?.cloudaicompanionProject?.trim() || + process.env.GOOGLE_CLOUD_PROJECT?.trim() || + process.env.GOOGLE_CLOUD_PROJECT_ID?.trim() + if (projectId) return projectId + const reason = payload?.ineligibleTiers + ?.map((tier) => tier.reasonMessage?.trim()) + .filter(Boolean) + .join('; ') + throw new Error( + reason || + 'Gemini CLI account setup is incomplete. Run `gemini` once to finish Google subscription onboarding.' + ) + } + + private async postStream(input: { + body: Record + accessToken: string + signal: AbortSignal + round: LlmDebugRound | null + attempt: number + reason: 'initial' | 'credential_refresh' | 'transport_retry' + }): Promise<{ response?: Response; error?: string }> { + const url = `${this.methodUrl('streamGenerateContent')}?alt=sse` + const headers = geminiHeaders(input.accessToken) + const trace = input.round && this.debugSink + ? safeDebug(() => this.debugSink!.beginHttpAttempt(input.round!, { + endpointFormat: 'gemini-cli-api', + attempt: input.attempt, + reason: input.reason, + url, + headers, + bodyText: traceSafeBody(input.body), + secretValues: [input.accessToken] + })) + : undefined + try { + const response = await this.fetchImpl(url, { + method: 'POST', + headers, + body: JSON.stringify(input.body), + signal: input.signal + }) + if (trace && input.round && this.debugSink) { + safeDebug(() => this.debugSink!.captureHttpResponse(input.round!, trace, response)) + } + return { response } + } catch (error) { + if (trace && this.debugSink) { + safeDebug(() => this.debugSink!.captureHttpError(trace, error)) + } + return { + error: input.signal.aborted + ? 'Gemini CLI API request was aborted.' + : `Gemini CLI API request failed: ${safeErrorMessage(error)}` + } + } + } + + private methodUrl(method: string): string { + return `${this.endpoint}/${this.apiVersion}:${method}` + } +} + +export function buildGeminiCliCodeAssistRequest( + request: ModelRequest, + model: string, + projectId: string +): Record { + const messages = projectCompatMessages(request, { + thinkingMode: false, + supportsImages: true + }) + const projected = messagesToGemini(messages, request) + const generationConfig: Record = {} + if (request.temperature !== undefined) generationConfig.temperature = request.temperature + if (request.topP !== undefined) generationConfig.topP = request.topP + if (request.maxTokens !== undefined) generationConfig.maxOutputTokens = request.maxTokens + if (request.responseFormat === 'json_object') { + generationConfig.responseMimeType = 'application/json' + } + const thinkingConfig = geminiThinkingConfig(request.reasoningEffort) + if (thinkingConfig) generationConfig.thinkingConfig = thinkingConfig + + const inner: Record = { + contents: projected.contents, + ...(projected.systemInstruction + ? { systemInstruction: { role: 'user', parts: [{ text: projected.systemInstruction }] } } + : {}), + ...(request.tools.length + ? { + tools: [{ + functionDeclarations: request.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + parametersJsonSchema: tool.inputSchema + })) + }], + toolConfig: { + functionCallingConfig: request.requiredToolName + ? { mode: 'ANY', allowedFunctionNames: [request.requiredToolName] } + : { mode: 'AUTO' } + } + } + : {}), + ...(Object.keys(generationConfig).length ? { generationConfig } : {}), + session_id: request.threadId + } + + return { + model, + project: projectId, + user_prompt_id: randomUUID(), + request: inner + } +} + +function messagesToGemini( + messages: CompatChatMessage[], + request: ModelRequest +): { systemInstruction: string; contents: GeminiContent[] } { + const systems: string[] = [] + const contents: GeminiContent[] = [] + const toolNames = new Map() + for (const message of messages) { + for (const call of message.tool_calls ?? []) { + toolNames.set(call.id, call.function.name) + } + } + const metadataByCallId = new Map() + for (const item of [...request.prefix, ...request.history]) { + if (item.kind === 'tool_call' && item.providerMetadata?.gemini) { + metadataByCallId.set(item.callId, item.providerMetadata) + } + } + + for (const message of messages) { + if (message.role === 'system') { + const text = compatContentText(message.content).trim() + if (text) systems.push(text) + continue + } + if (message.role === 'tool') { + if (!message.tool_call_id) continue + appendGeminiContent(contents, { + role: 'user', + parts: [{ + functionResponse: { + id: message.tool_call_id, + name: toolNames.get(message.tool_call_id) ?? 'tool', + response: { output: compatContentText(message.content) } + } + }] + }) + continue + } + const parts = compatContentParts(message.content) + for (const call of message.tool_calls ?? []) { + const signature = metadataByCallId.get(call.id)?.gemini?.thoughtSignature + parts.push({ + functionCall: { + id: call.id, + name: call.function.name, + args: parseObject(call.function.arguments) + }, + ...(signature ? { thoughtSignature: signature } : {}) + }) + } + if (parts.length > 0) { + appendGeminiContent(contents, { + role: message.role === 'assistant' ? 'model' : 'user', + parts + }) + } + } + return { systemInstruction: systems.join('\n\n'), contents } +} + +function appendGeminiContent(contents: GeminiContent[], next: GeminiContent): void { + const previous = contents.at(-1) + if (previous?.role === next.role) { + previous.parts.push(...next.parts) + } else { + contents.push(next) + } +} + +function compatContentParts( + content: CompatChatMessage['content'] +): GeminiPart[] { + if (typeof content === 'string') return content ? [{ text: content }] : [] + if (!content) return [] + const out: GeminiPart[] = [] + for (const part of content) { + if (part.type === 'text') { + if (part.text) out.push({ text: part.text }) + continue + } + const image = dataUri(part) + if (image) out.push({ inlineData: image }) + else out.push({ text: `[image unavailable to Gemini CLI API: ${part.image_url.url}]` }) + } + return out +} + +function compatContentText(content: CompatChatMessage['content']): string { + if (typeof content === 'string') return content + if (!content) return '' + return content.map((part) => + part.type === 'text' ? part.text : `[image: ${part.image_url.url}]` + ).join('\n') +} + +function dataUri( + part: Extract +): { mimeType: string; data: string } | null { + const match = /^data:([^;,]+);base64,(.*)$/is.exec(part.image_url.url) + return match ? { mimeType: match[1], data: match[2] } : null +} + +function geminiThinkingConfig(effort: string | undefined): Record | null { + switch (effort?.trim().toLowerCase()) { + case 'off': + return { thinkingBudget: 0, includeThoughts: false } + case 'low': + return { thinkingBudget: 1_024, includeThoughts: true } + case 'high': + case 'max': + case 'xhigh': + return { thinkingBudget: 16_384, includeThoughts: true } + case 'medium': + return { thinkingBudget: 8_192, includeThoughts: true } + default: + return null + } +} + +async function *readGeminiSse( + body: ReadableStream, + signal: AbortSignal +): AsyncIterable { + const reader = body.getReader() + const decoder = new TextDecoder() + const frames = new IncrementalSseFrameBuffer() + let totalBytes = 0 + try { + while (true) { + if (signal.aborted) throw new Error('Gemini CLI API stream was aborted.') + const { value, done } = await reader.read() + if (done) break + totalBytes += value?.byteLength ?? 0 + if (totalBytes > MAX_STREAM_BYTES) { + throw new Error(`Gemini CLI API stream exceeded ${MAX_STREAM_BYTES} bytes.`) + } + frames.append(decoder.decode(value, { stream: true })) + let frame = frames.takeFrame() + while (frame) { + if (Buffer.byteLength(frame.data, 'utf8') > MAX_SSE_FRAME_BYTES) { + throw new Error(`Gemini CLI API SSE frame exceeded ${MAX_SSE_FRAME_BYTES} bytes.`) + } + const data = frame.data + .split(/\r?\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trimStart()) + .join('\n') + .trim() + if (data && data !== '[DONE]') { + let parsed: GeminiCodeAssistResponse + try { + parsed = JSON.parse(data) as GeminiCodeAssistResponse + } catch { + throw new Error('Gemini CLI API returned malformed SSE JSON.') + } + if (parsed.error) { + throw new Error(providerErrorMessage(parsed.error, parsed.error.code ?? 500)) + } + yield parsed + } + frame = frames.takeFrame() + } + } + } finally { + reader.releaseLock() + } +} + +function normalizeGeminiUsage( + usage: GeminiUsageMetadata, + providerId: string, + model: string +): UsageSnapshot { + const promptTokens = nonNegativeInt(usage.promptTokenCount) + const completionTokens = nonNegativeInt(usage.candidatesTokenCount) + const reasoningTokens = nonNegativeInt(usage.thoughtsTokenCount) + const totalTokens = nonNegativeInt(usage.totalTokenCount) || + promptTokens + completionTokens + reasoningTokens + const cacheHitTokens = nonNegativeInt(usage.cachedContentTokenCount) + const cacheMissTokens = Math.max(0, promptTokens - cacheHitTokens) + const cacheable = cacheHitTokens + cacheMissTokens + return { + promptTokens, + completionTokens, + ...(reasoningTokens > 0 ? { reasoningTokens } : {}), + totalTokens, + actualProviderId: providerId, + actualModelId: model, + cachedTokens: cacheHitTokens, + cacheHitTokens, + cacheMissTokens, + cacheHitRate: cacheable > 0 ? cacheHitTokens / cacheable : null, + turns: 1 + } +} + +async function readGeminiError(response: Response): Promise<{ + message: string + status?: string + retryAfterMs?: number +}> { + const text = (await response.text()).slice(0, MAX_ERROR_BODY_BYTES) + let payload: GeminiCodeAssistResponse | null = null + try { + payload = JSON.parse(text) as GeminiCodeAssistResponse + } catch { + // A bounded plain-text error still produces a useful conversation card. + } + const retryAfterMs = parseRetryAfterMs(response.headers.get('retry-after')) ?? + parseGoogleRetryDurationMs(payload?.error?.message ?? text) + return { + message: providerErrorMessage(payload?.error, response.status, text), + ...(payload?.error?.status ? { status: payload.error.status } : {}), + ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) + } +} + +async function geminiRetryDelayMs( + response: Response, + initialDelayMs: number, + attempt: number +): Promise { + const headerDelay = parseRetryAfterMs(response.headers.get('retry-after')) + if (headerDelay !== undefined) return headerDelay + if (response.status === 429) { + const text = (await response.clone().text().catch(() => '')).slice(0, MAX_ERROR_BODY_BYTES) + const providerDelay = parseGoogleRetryDurationMs(text) + if (providerDelay !== undefined) return Math.min(60_000, providerDelay) + } + return retryDelayMs(response, initialDelayMs, attempt) +} + +function parseGoogleRetryDurationMs(value: string): number | undefined { + const match = /(?:quota will reset after|please retry in)\s*((?:\d+(?:\.\d+)?(?:ms|[smhd]))+)/i.exec(value) + if (!match?.[1]) return undefined + let total = 0 + const units = /(\d+(?:\.\d+)?)(ms|[smhd])/gi + let part: RegExpExecArray | null + let parsed = false + while ((part = units.exec(match[1])) !== null) { + parsed = true + const amount = Number(part[1]) + if (!Number.isFinite(amount) || amount < 0) continue + const multiplier = part[2].toLowerCase() === 'ms' + ? 1 + : part[2].toLowerCase() === 's' + ? 1_000 + : part[2].toLowerCase() === 'm' + ? 60_000 + : part[2].toLowerCase() === 'h' + ? 3_600_000 + : 86_400_000 + total += amount * multiplier + } + return parsed ? Math.min(3_600_000, Math.round(total)) : undefined +} + +function providerErrorMessage( + error: GeminiCodeAssistResponse['error'] | undefined, + status: number, + fallback = '' +): string { + const detail = error?.message?.trim() || fallback.replace(/\s+/g, ' ').trim() + return `Gemini CLI API request failed (${error?.status || `HTTP ${status}`}): ${ + boundedText(detail || 'Unknown provider error') + }` +} + +function geminiErrorCode(status: number, providerStatus?: string): string { + if (status === 401 || status === 403) return 'gemini_cli_auth_failed' + if (status === 429 || providerStatus === 'RESOURCE_EXHAUSTED') return 'rate_limit_exceeded' + if (status >= 500) return 'gemini_cli_api_unavailable' + return 'gemini_cli_api_request_failed' +} + +function geminiHeaders(accessToken: string): Record { + return { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json', + 'user-agent': 'google-gemini-cli', + 'x-goog-api-client': 'gl-node/kun gemini-cli-api' + } +} + +function geminiProviderMetadata( + thoughtSignature: string | undefined +): ToolCallProviderMetadata | null { + const signature = thoughtSignature?.trim() + if (!signature || signature.length > 131_072) return null + return { gemini: { thoughtSignature: signature } } +} + +function traceSafeBody(body: Record): string { + return JSON.stringify(body, (key, value) => + key === 'thoughtSignature' ? '[REDACTED]' : value + ) +} + +function parseObject(value: string): Record { + try { + return objectValue(JSON.parse(value) as unknown) + } catch { + return {} + } +} + +function objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {} +} + +function nonNegativeInt(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) + ? Math.max(0, Math.trunc(value)) + : 0 +} + +function boundedText(value: string): string { + const normalized = value.replace(/\s+/g, ' ').trim() + return normalized.length > 2_000 ? `${normalized.slice(0, 2_000)}…` : normalized +} + +function safeErrorMessage(error: unknown): string { + return boundedText(error instanceof Error ? error.message : String(error)) +} + +function isUnauthorized(error: unknown): boolean { + return error instanceof GeminiCliApiHttpError && error.status === 401 +} + +class GeminiCliApiHttpError extends Error { + constructor(readonly status: number, message: string) { + super(message) + this.name = 'GeminiCliApiHttpError' + } +} + +function safeDebug(action: () => T): T | undefined { + try { + return action() + } catch { + return undefined + } +} diff --git a/kun/src/adapters/model/gemini-cli-oauth.test.ts b/kun/src/adapters/model/gemini-cli-oauth.test.ts new file mode 100644 index 000000000..a20bc9097 --- /dev/null +++ b/kun/src/adapters/model/gemini-cli-oauth.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest' +import { + GEMINI_CLI_OAUTH_TOKEN_URL, + GeminiCliOAuthSource, + normalizeGeminiCliCredential +} from './gemini-cli-oauth.js' + +describe('GeminiCliOAuthSource', () => { + it('normalizes both legacy file and current keychain credential shapes', () => { + expect(normalizeGeminiCliCredential({ + access_token: 'legacy-access', + refresh_token: 'legacy-refresh', + expiry_date: 123 + })).toEqual({ + accessToken: 'legacy-access', + refreshToken: 'legacy-refresh', + expiresAt: 123 + }) + expect(normalizeGeminiCliCredential({ + token: { + accessToken: 'keychain-access', + refreshToken: 'keychain-refresh', + expiresAt: 456, + tokenType: 'Bearer' + } + })).toEqual({ + accessToken: 'keychain-access', + refreshToken: 'keychain-refresh', + expiresAt: 456, + tokenType: 'Bearer' + }) + }) + + it('reuses a fresh official CLI access token without a network call', async () => { + const fetchImpl = vi.fn() + const source = new GeminiCliOAuthSource({ + now: () => 1_000, + fetchImpl: fetchImpl as unknown as typeof fetch, + loadCredential: async () => ({ + accessToken: 'fresh-access', + refreshToken: 'refresh', + expiresAt: 100_000 + }) + }) + + await expect(source.accessToken()).resolves.toBe('fresh-access') + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it('refreshes an expired token in memory without exposing the refresh token', async () => { + const fetchImpl = vi.fn(async (url: string, init: RequestInit) => { + expect(url).toBe(GEMINI_CLI_OAUTH_TOKEN_URL) + expect(String(init.body)).toContain('refresh_token=official-refresh') + return new Response(JSON.stringify({ + access_token: 'next-access', + expires_in: 3600, + token_type: 'Bearer' + }), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + }) + const source = new GeminiCliOAuthSource({ + now: () => 10_000, + fetchImpl: fetchImpl as unknown as typeof fetch, + loadCredential: async () => ({ + accessToken: 'expired-access', + refreshToken: 'official-refresh', + expiresAt: 9_000 + }) + }) + + await expect(source.accessToken()).resolves.toBe('next-access') + await expect(source.accessToken()).resolves.toBe('next-access') + expect(fetchImpl).toHaveBeenCalledOnce() + }) + + it('returns login guidance when the official CLI has no usable credential', async () => { + const source = new GeminiCliOAuthSource({ + loadCredential: async () => null + }) + + await expect(source.accessToken()).rejects.toThrow( + 'Run `gemini`, choose “Login with Google”' + ) + }) +}) diff --git a/kun/src/adapters/model/gemini-cli-oauth.ts b/kun/src/adapters/model/gemini-cli-oauth.ts new file mode 100644 index 000000000..e3bc198a7 --- /dev/null +++ b/kun/src/adapters/model/gemini-cli-oauth.ts @@ -0,0 +1,239 @@ +import { execFile } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) + +// These are the installed-application OAuth identifiers published by the +// Apache-2.0 licensed official Gemini CLI. Installed-app client secrets are +// intentionally embedded application identifiers, not user credentials. +export const GEMINI_CLI_OAUTH_CLIENT_ID = + '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com' +export const GEMINI_CLI_OAUTH_CLIENT_SECRET = + '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' +export const GEMINI_CLI_OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token' + +export type GeminiCliOAuthCredential = { + accessToken?: string + refreshToken?: string + expiresAt?: number + tokenType?: string + scope?: string +} + +export type GeminiCliOAuthSourceOptions = { + credentialPath?: string + fetchImpl?: typeof fetch + now?: () => number + loadCredential?: () => Promise +} + +/** + * Loads the OAuth login owned by the official Gemini CLI and refreshes it in + * memory. Kun never copies the credential into settings, config.json, traces, + * or its own account store. + */ +export class GeminiCliOAuthSource { + private readonly credentialPath: string + private readonly fetchImpl: typeof fetch + private readonly now: () => number + private readonly loadCredentialOverride?: () => Promise + private credential: GeminiCliOAuthCredential | null = null + + constructor(options: GeminiCliOAuthSourceOptions = {}) { + this.credentialPath = options.credentialPath ?? + process.env.KUN_GEMINI_CLI_OAUTH_PATH?.trim() ?? + join(homedir(), '.gemini', 'oauth_creds.json') + this.fetchImpl = options.fetchImpl ?? fetch + this.now = options.now ?? Date.now + this.loadCredentialOverride = options.loadCredential + } + + async accessToken(rejectedAccessToken?: string): Promise { + const credential = this.credential ?? await this.loadCredential() + this.credential = credential + + const accessToken = credential.accessToken?.trim() + const usable = Boolean( + accessToken && + accessToken !== rejectedAccessToken && + (!credential.expiresAt || credential.expiresAt > this.now() + 60_000) + ) + if (usable) return accessToken! + + const refreshToken = credential.refreshToken?.trim() + if (!refreshToken) { + throw geminiCliLoginRequired() + } + const refreshed = await this.refresh(refreshToken) + this.credential = { + ...credential, + accessToken: refreshed.accessToken, + expiresAt: refreshed.expiresAt, + tokenType: refreshed.tokenType ?? credential.tokenType, + scope: refreshed.scope ?? credential.scope + } + return refreshed.accessToken + } + + private async loadCredential(): Promise { + const loaded = this.loadCredentialOverride + ? await this.loadCredentialOverride() + : await loadOfficialGeminiCliCredential(this.credentialPath) + if (!loaded || (!loaded.accessToken?.trim() && !loaded.refreshToken?.trim())) { + throw geminiCliLoginRequired() + } + return loaded + } + + private async refresh(refreshToken: string): Promise<{ + accessToken: string + expiresAt?: number + tokenType?: string + scope?: string + }> { + let response: Response + try { + response = await this.fetchImpl(GEMINI_CLI_OAUTH_TOKEN_URL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: GEMINI_CLI_OAUTH_CLIENT_ID, + client_secret: GEMINI_CLI_OAUTH_CLIENT_SECRET, + refresh_token: refreshToken, + grant_type: 'refresh_token' + }) + }) + } catch (error) { + throw new Error(`Gemini CLI OAuth refresh failed: ${safeErrorMessage(error)}`) + } + const payload = await response.json().catch(() => null) as { + access_token?: unknown + expires_in?: unknown + token_type?: unknown + scope?: unknown + error?: unknown + error_description?: unknown + } | null + const nextAccessToken = + typeof payload?.access_token === 'string' ? payload.access_token.trim() : '' + if (!response.ok || !nextAccessToken) { + const detail = typeof payload?.error_description === 'string' + ? payload.error_description + : typeof payload?.error === 'string' + ? payload.error + : `HTTP ${response.status}` + throw new Error( + `Gemini CLI OAuth refresh failed: ${boundedText(detail)}. Run \`gemini\` and sign in with Google again.` + ) + } + const expiresIn = typeof payload?.expires_in === 'number' && Number.isFinite(payload.expires_in) + ? Math.max(0, payload.expires_in) + : undefined + return { + accessToken: nextAccessToken, + ...(expiresIn !== undefined ? { expiresAt: this.now() + expiresIn * 1_000 } : {}), + ...(typeof payload?.token_type === 'string' ? { tokenType: payload.token_type } : {}), + ...(typeof payload?.scope === 'string' ? { scope: payload.scope } : {}) + } + } +} + +export async function loadOfficialGeminiCliCredential( + legacyCredentialPath = join(homedir(), '.gemini', 'oauth_creds.json') +): Promise { + // New Gemini CLI releases use the OS credential store. macOS exposes the + // same service/account pair through the `security` command without adding a + // native keychain dependency to the packaged Kun runtime. + if (process.platform === 'darwin') { + const fromKeychain = await loadMacKeychainCredential() + if (fromKeychain) return fromKeychain + } + try { + const parsed = JSON.parse(await readFile(legacyCredentialPath, 'utf8')) as unknown + return normalizeGeminiCliCredential(parsed) + } catch (error) { + if (isMissingFile(error)) return null + if (error instanceof SyntaxError) { + throw new Error( + `Gemini CLI OAuth credential is malformed. Run \`gemini\` and sign in again.` + ) + } + throw new Error(`Unable to read Gemini CLI OAuth credential: ${safeErrorMessage(error)}`) + } +} + +export function normalizeGeminiCliCredential(value: unknown): GeminiCliOAuthCredential | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const record = value as Record + const token = record.token && typeof record.token === 'object' && !Array.isArray(record.token) + ? record.token as Record + : null + const accessToken = stringValue(record.access_token) ?? stringValue(token?.accessToken) + const refreshToken = stringValue(record.refresh_token) ?? stringValue(token?.refreshToken) + const expiresAt = numberValue(record.expiry_date) ?? numberValue(token?.expiresAt) + const tokenType = stringValue(record.token_type) ?? stringValue(token?.tokenType) + const scope = stringValue(record.scope) ?? stringValue(token?.scope) + if (!accessToken && !refreshToken) return null + return { + ...(accessToken ? { accessToken } : {}), + ...(refreshToken ? { refreshToken } : {}), + ...(expiresAt !== undefined ? { expiresAt } : {}), + ...(tokenType ? { tokenType } : {}), + ...(scope ? { scope } : {}) + } +} + +async function loadMacKeychainCredential(): Promise { + try { + const { stdout } = await execFileAsync('/usr/bin/security', [ + 'find-generic-password', + '-s', + 'gemini-cli-oauth', + '-a', + 'main-account', + '-w' + ], { + encoding: 'utf8', + timeout: 5_000, + maxBuffer: 512 * 1024 + }) + return normalizeGeminiCliCredential(JSON.parse(stdout.trim()) as unknown) + } catch { + return null + } +} + +function geminiCliLoginRequired(): Error { + return new Error( + 'Gemini CLI Google login was not found. Run `gemini`, choose “Login with Google”, and then retry this turn.' + ) +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function numberValue(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function isMissingFile(error: unknown): boolean { + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + (error as { code?: unknown }).code === 'ENOENT' + ) +} + +function safeErrorMessage(error: unknown): string { + return boundedText(error instanceof Error ? error.message : String(error)) +} + +function boundedText(value: string): string { + const normalized = value.replace(/\s+/g, ' ').trim() + return normalized.length > 1_000 ? `${normalized.slice(0, 1_000)}…` : normalized +} diff --git a/kun/src/adapters/model/model-error-probe.ts b/kun/src/adapters/model/model-error-probe.ts index dee860363..0b98d4d01 100644 --- a/kun/src/adapters/model/model-error-probe.ts +++ b/kun/src/adapters/model/model-error-probe.ts @@ -13,6 +13,14 @@ export function isDeepSeekHost(baseUrl: string): boolean { } } +export function isGeminiOpenAiHost(baseUrl: string): boolean { + try { + return new URL(baseUrl).hostname.toLowerCase() === 'generativelanguage.googleapis.com' + } catch { + return false + } +} + export async function probeDeepSeekReachable(input: { baseUrl: string fetchImpl: typeof fetch diff --git a/kun/src/adapters/model/model-stream-resource-budget.ts b/kun/src/adapters/model/model-stream-resource-budget.ts index 57899ffd6..46ae43217 100644 --- a/kun/src/adapters/model/model-stream-resource-budget.ts +++ b/kun/src/adapters/model/model-stream-resource-budget.ts @@ -33,10 +33,11 @@ export const DEFAULT_MODEL_STREAM_LIMITS: ModelStreamLimits = { maxTotalBytes: 32 * 1024 * 1024, maxFrames: 65_536, maxOutputBytes: 8 * 1024 * 1024, - maxPendingToolCalls: 32, + // High backstop so long agent turns are not cut off; byte limits still bind memory. + maxPendingToolCalls: 10_000, maxPendingToolArgumentBytes: 1 * 1024 * 1024, maxTotalPendingToolArgumentBytes: 4 * 1024 * 1024, - maxCompletedToolCalls: 32, + maxCompletedToolCalls: 10_000, maxCompletedToolArgumentBytes: 4 * 1024 * 1024 } diff --git a/kun/src/adapters/model/provider-cli-identity.test.ts b/kun/src/adapters/model/provider-cli-identity.test.ts new file mode 100644 index 000000000..27d4ae408 --- /dev/null +++ b/kun/src/adapters/model/provider-cli-identity.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + CODEX_CLI_VERSION, + GROK_CLI_CLIENT_IDENTIFIER, + GROK_CLI_VERSION, + codexCliRequestHeaders, + geminiCliRequestHeaders, + grokCliMediaHeaders, + grokCliProxyHeaders +} from './provider-cli-identity.js' + +describe('provider-cli-identity', () => { + it('builds Codex CLI headers with the pinned CLI version and no Kun branding', () => { + const headers = codexCliRequestHeaders({ accountId: 'acct_1', sessionId: 'sess_1' }) + expect(headers['User-Agent']).toContain(`codex_cli_rs/${CODEX_CLI_VERSION}`) + expect(headers.originator).toBe('codex_cli_rs') + expect(JSON.stringify(headers)).not.toMatch(/deepseekgui|kun/i) + }) + + it('builds Gemini CLI headers with the known-working Code Assist identity', () => { + expect(geminiCliRequestHeaders({ purpose: 'api' })).toEqual({ + 'user-agent': 'google-gemini-cli', + 'x-goog-api-client': 'gl-node/kun gemini-cli-api' + }) + expect(geminiCliRequestHeaders({ purpose: 'audio' })).toEqual({ + 'user-agent': 'google-gemini-cli', + 'x-goog-api-client': 'gl-node/kun gemini-cli-audio' + }) + }) + + it('builds Grok Build CLI headers with grok-shell identity', () => { + expect(grokCliProxyHeaders()).toMatchObject({ + 'X-XAI-Token-Auth': 'xai-grok-cli', + 'x-grok-client-version': GROK_CLI_VERSION, + 'x-grok-client-mode': 'interactive' + }) + expect(grokCliMediaHeaders()).toEqual({ + 'User-Agent': `xai-grok-build/${GROK_CLI_VERSION}`, + 'x-grok-client-version': GROK_CLI_VERSION, + 'x-grok-client-identifier': GROK_CLI_CLIENT_IDENTIFIER + }) + expect(GROK_CLI_CLIENT_IDENTIFIER).toBe('grok-shell') + }) +}) diff --git a/kun/src/adapters/model/provider-cli-identity.ts b/kun/src/adapters/model/provider-cli-identity.ts new file mode 100644 index 000000000..37e896d08 --- /dev/null +++ b/kun/src/adapters/model/provider-cli-identity.ts @@ -0,0 +1,62 @@ +import { arch, release, type as osType } from 'node:os' + +/** Pinned to current npm @openai/codex; bump when subscription checks require a newer CLI. */ +export const CODEX_CLI_VERSION = '0.145.0' +/** Pinned to current npm @google/gemini-cli. */ +export const GEMINI_CLI_VERSION = '0.52.0' +/** Pinned to current grok / Grok Build CLI. */ +export const GROK_CLI_VERSION = '0.2.112' + +export const CODEX_CLI_ORIGINATOR = 'codex_cli_rs' +export const GROK_CLI_TOKEN_AUTH = 'xai-grok-cli' +export const GROK_CLI_CLIENT_IDENTIFIER = 'grok-shell' + +export function codexCliUserAgent(version = CODEX_CLI_VERSION): string { + return `${CODEX_CLI_ORIGINATOR}/${version} (${osType()} ${release()}; ${arch()})` +} + +export function codexCliRequestHeaders(input: { + accountId: string + sessionId: string +}): Record { + return { + 'ChatGPT-Account-Id': input.accountId, + originator: CODEX_CLI_ORIGINATOR, + 'OpenAI-Beta': 'responses=experimental', + 'User-Agent': codexCliUserAgent(), + session_id: input.sessionId + } +} + +/** + * Known-working Code Assist client identity (pre GeminiCLI/{ver}/{model} UA). + * Kept here for shared STT/API callers; currently inlined at call sites for parity. + */ +export function geminiCliRequestHeaders(input: { + model?: string + purpose?: 'api' | 'audio' +} = {}): Record { + void input.model + const purpose = input.purpose === 'audio' ? 'audio' : 'api' + return { + 'user-agent': 'google-gemini-cli', + 'x-goog-api-client': `gl-node/kun gemini-cli-${purpose}` + } +} + +export function grokCliProxyHeaders(version = GROK_CLI_VERSION): Record { + return { + 'X-XAI-Token-Auth': GROK_CLI_TOKEN_AUTH, + 'x-authenticateresponse': 'authenticate-response', + 'x-grok-client-version': version, + 'x-grok-client-mode': 'interactive' + } +} + +export function grokCliMediaHeaders(version = GROK_CLI_VERSION): Record { + return { + 'User-Agent': `xai-grok-build/${version}`, + 'x-grok-client-version': version, + 'x-grok-client-identifier': GROK_CLI_CLIENT_IDENTIFIER + } +} diff --git a/kun/src/adapters/model/route-pool-model-client.ts b/kun/src/adapters/model/route-pool-model-client.ts index d1090ace8..54ec1671e 100644 --- a/kun/src/adapters/model/route-pool-model-client.ts +++ b/kun/src/adapters/model/route-pool-model-client.ts @@ -172,7 +172,7 @@ export class RoutePoolModelClient implements ModelClient { constructor( private readonly direct: ModelClient, pools: readonly ModelRoutePoolConfig[], - private readonly capabilities: (model: string) => ModelCapabilityMetadata, + private readonly capabilities: (model: string, providerId?: string) => ModelCapabilityMetadata, readonly health: RoutePoolHealthStore = new RoutePoolHealthStore(), private readonly now: () => number = Date.now ) { @@ -300,8 +300,12 @@ function shouldRouteRequest(pool: ModelRoutePoolConfig, request: ModelRequest): return providerId === LOCAL_MODEL_GATEWAY_PROVIDER_ID || providerId === `route-pool:${pool.id}`.toLowerCase() } -function targetSupportsRequest(target: ModelRouteTargetConfig, request: ModelRequest, resolve: (model: string) => ModelCapabilityMetadata): boolean { - const capability = resolve(target.modelId) +function targetSupportsRequest( + target: ModelRouteTargetConfig, + request: ModelRequest, + resolve: (model: string, providerId?: string) => ModelCapabilityMetadata +): boolean { + const capability = resolve(target.modelId, target.providerId) if (request.attachments?.length && !capability.inputModalities.includes('image')) return false if (request.tools.length > 0 && !capability.supportsToolCalling) return false if (request.reasoningEffort && request.reasoningEffort !== 'off' && !capability.reasoning) return false diff --git a/kun/src/adapters/tool/builtin-bash-tool.test.ts b/kun/src/adapters/tool/builtin-bash-tool.test.ts new file mode 100644 index 000000000..c73599fcd --- /dev/null +++ b/kun/src/adapters/tool/builtin-bash-tool.test.ts @@ -0,0 +1,80 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, expect, it, vi } from 'vitest' +import type { ToolHostContext } from '../../ports/tool-host.js' +import type { BackgroundShellRecordInput } from './builtin-tool-types.js' +import { createBashLocalTool } from './builtin-bash-tool.js' + +vi.mock('./local-tool-host.js', () => ({ + LocalToolHost: { + defineTool: (tool: unknown) => tool + } +})) + +const TEST_TIMEOUT_MS = 10_000 + +async function withTimeout(promise: Promise): Promise { + let timer: NodeJS.Timeout | undefined + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('background shell did not settle')), TEST_TIMEOUT_MS) + }) + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +describe('background bash progress', () => { + it('keeps session updates live without updating the tool call after handoff', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'kun-background-bash-')) + const toolUpdates = vi.fn() + const sessionUpdates = vi.fn() + let settleSession: ((record: BackgroundShellRecordInput) => void) | undefined + const settled = new Promise((resolve) => { + settleSession = resolve + }) + const tool = createBashLocalTool({ + backgroundShellDataDir: workspace, + defaultTimeoutSeconds: 5, + backgroundShell: { + onSessionUpdated: sessionUpdates, + onSessionSettled: (record) => settleSession?.(record) + } + }) + const context = { + threadId: 'thread_background', + turnId: 'turn_background', + workspace, + approvalPolicy: 'auto', + sandboxMode: 'workspace-write', + abortSignal: new AbortController().signal, + awaitApproval: async () => 'allow' as const + } as ToolHostContext + + try { + const result = await tool.execute({ + command: 'node -e "setTimeout(() => console.log(\'late-output\'), 100); setTimeout(() => {}, 300)"', + background: true + }, context, toolUpdates) + const updatesAtHandoff = toolUpdates.mock.calls.length + + expect(result.output).toMatchObject({ + status: 'running', + partial: true + }) + + const terminal = await withTimeout(settled) + + expect(terminal.status).toBe('completed') + expect(terminal.output).toContain('late-output') + expect(sessionUpdates).toHaveBeenCalled() + expect(toolUpdates).toHaveBeenCalledTimes(updatesAtHandoff) + } finally { + await rm(workspace, { recursive: true, force: true }) + } + }, TEST_TIMEOUT_MS) +}) diff --git a/kun/src/adapters/tool/builtin-bash-tool.ts b/kun/src/adapters/tool/builtin-bash-tool.ts index ec866bd9b..3f81f9843 100644 --- a/kun/src/adapters/tool/builtin-bash-tool.ts +++ b/kun/src/adapters/tool/builtin-bash-tool.ts @@ -758,13 +758,16 @@ async function startBackgroundBashSession( let updateTimer: NodeJS.Timeout | undefined let lastUpdateAt = 0 let liveUpdates = true + let liveToolUpdates = true let updateInFlight: Promise | undefined const flushUpdate = async () => { - if (!liveUpdates || !onUpdate || !updateDirty) return + if (!liveUpdates || (!onUpdate && !hooks?.onSessionUpdated) || !updateDirty) return updateDirty = false lastUpdateAt = Date.now() const payload = await sessionPayload(session) - await onUpdate({ output: payload }) + if (liveToolUpdates && onUpdate) { + await onUpdate({ output: payload }) + } // Do not enqueue a stale "running" update after the process has reached // a terminal state and its completion notification is being published. if (liveUpdates) notifyUpdated() @@ -781,7 +784,7 @@ async function startBackgroundBashSession( }) } const scheduleUpdate = () => { - if (!liveUpdates || !onUpdate) return + if (!liveUpdates || (!onUpdate && !hooks?.onSessionUpdated)) return updateDirty = true const delay = 100 - (Date.now() - lastUpdateAt) if (delay <= 0) { @@ -848,6 +851,11 @@ async function startBackgroundBashSession( await startedNotification if (input.detached) { + // The bash tool call is complete once the detached session has been + // handed off. Keep lifecycle hooks live for the background-shell API, but + // prevent later process output from updating the completed tool_result. + liveToolUpdates = false + await updateInFlight?.catch(() => undefined) const timeoutMs = input.timeoutSeconds * 1000 const timeoutTimer = setTimeout(() => { if (session.status !== 'running') return diff --git a/kun/src/adapters/tool/capability-registry.test.ts b/kun/src/adapters/tool/capability-registry.test.ts index 4a61225d8..d5aef69aa 100644 --- a/kun/src/adapters/tool/capability-registry.test.ts +++ b/kun/src/adapters/tool/capability-registry.test.ts @@ -3,21 +3,23 @@ import type { ToolHostContext } from '../../ports/tool-host.js' import { LocalToolHost } from './local-tool-host.js' import { CapabilityRegistry } from './capability-registry.js' -function tool(name: string) { +function tool(name: string, sideEffect?: 'read-only' | 'unknown') { return LocalToolHost.defineTool({ name, description: name, inputSchema: { type: 'object', properties: {} }, policy: 'auto', + ...(sideEffect ? { sideEffect } : {}), execute: async () => ({ output: { ok: true } }) }) } -function context(activeSkillIds: string[]): ToolHostContext { +function context(activeSkillIds: string[], threadMode?: 'agent' | 'plan'): ToolHostContext { return { threadId: 'thread_1', turnId: 'turn_1', workspace: '/workspace', + ...(threadMode ? { threadMode } : {}), activeSkillIds, approvalPolicy: 'auto', sandboxMode: 'danger-full-access', @@ -47,3 +49,21 @@ describe('CapabilityRegistry managed skill policy', () => { .toThrow('tool background_shell is not advertised by active tool policy') }) }) + +describe('CapabilityRegistry Plan mode policy', () => { + it('allows host-classified read-only tools and blocks unknown external tools', () => { + const registry = new CapabilityRegistry([{ + id: 'mcp:test', + kind: 'mcp', + enabled: true, + available: true, + tools: [tool('mcp_test_lookup', 'read-only'), tool('mcp_test_mutate')] + }]) + const planContext = context([], 'plan') + + expect(registry.listTools(planContext).map((spec) => spec.name)).toEqual(['mcp_test_lookup']) + expect(registry.listTools(planContext)[0]).toMatchObject({ sideEffect: 'read-only' }) + expect(() => registry.resolveTool('mcp_test_mutate', planContext)) + .toThrow('tool mcp_test_mutate is not advertised by active tool policy') + }) +}) diff --git a/kun/src/adapters/tool/capability-registry.ts b/kun/src/adapters/tool/capability-registry.ts index 31658efa7..40d73d249 100644 --- a/kun/src/adapters/tool/capability-registry.ts +++ b/kun/src/adapters/tool/capability-registry.ts @@ -20,6 +20,7 @@ export type CapabilityToolSpec = { description: string inputSchema: Record toolKind?: 'tool_call' | 'command_execution' | 'file_change' + sideEffect?: 'read-only' | 'unknown' providerId: string providerKind: ToolProviderKind } @@ -109,7 +110,7 @@ export class CapabilityRegistry { const specs: CapabilityToolSpec[] = [] for (const record of this.tools.values()) { if (!this.canUseProvider(record.provider, context)) continue - if (!this.canUseTool(record.tool.name, context)) continue + if (!this.canUseTool(record.tool, context)) continue if (!isToolAdvertisedInSandbox(record.tool, context)) continue if (record.tool.shouldAdvertise) { if (!context || !record.tool.shouldAdvertise(context)) continue @@ -119,6 +120,7 @@ export class CapabilityRegistry { description: record.tool.description, inputSchema: record.tool.inputSchema, toolKind: record.tool.toolKind, + ...(record.tool.sideEffect ? { sideEffect: record.tool.sideEffect } : {}), providerId: record.provider.id, providerKind: record.provider.kind }) @@ -137,7 +139,7 @@ export class CapabilityRegistry { if (!this.canUseProvider(record.provider, context)) { throw new Error(`tool ${toolName} is not advertised by provider ${record.provider.id}`) } - if (!this.canUseTool(toolName, context)) { + if (!this.canUseTool(record.tool, context)) { throw new Error(`tool ${toolName} is not advertised by active tool policy`) } if (record.tool.shouldAdvertise && !record.tool.shouldAdvertise(context)) { @@ -158,8 +160,13 @@ export class CapabilityRegistry { return true } - private canUseTool(toolName: string, context?: ToolHostContext): boolean { - if (isPlanModeContext(context) && !PLAN_MODE_ALLOWED_TOOL_NAMES.has(toolName)) { + private canUseTool(tool: LocalTool, context?: ToolHostContext): boolean { + const toolName = tool.name + if ( + isPlanModeContext(context) && + !PLAN_MODE_ALLOWED_TOOL_NAMES.has(toolName) && + tool.sideEffect !== 'read-only' + ) { return false } if (context?.blockedToolNames?.includes(toolName)) return false diff --git a/kun/src/adapters/tool/delegation-tool-provider.test.ts b/kun/src/adapters/tool/delegation-tool-provider.test.ts index b97532e64..071439b2d 100644 --- a/kun/src/adapters/tool/delegation-tool-provider.test.ts +++ b/kun/src/adapters/tool/delegation-tool-provider.test.ts @@ -1,13 +1,14 @@ import { describe, expect, it, vi } from 'vitest' import type { DelegationRuntime } from '../../delegation/delegation-runtime.js' import type { ToolHostContext } from '../../ports/tool-host.js' +import { CapabilityRegistry } from './capability-registry.js' import { buildDelegationToolProviders } from './delegation-tool-provider.js' +import { LocalToolHost } from './local-tool-host.js' describe('delegate_task observability output', () => { - it('exposes one mode-specific tool without child runtime selection fields', () => { + it('exposes discovery plus the switch-dependent delegate schema without child runtime selection fields', () => { const existingRuntime = { enabled: () => true, - listProfiles: () => [], useExistingAgents: true, defaultToolPolicy: 'inherit' } as unknown as DelegationRuntime @@ -15,21 +16,23 @@ describe('delegate_task observability output', () => { const delegateTool = tools[0] const properties = delegateTool?.inputSchema.properties as Record | undefined - expect(tools.map((tool) => tool.name)).toEqual(['delegate_task']) + expect(tools.map((tool) => tool.name)).toEqual(['delegate_task', 'list_subagent_profiles']) expect(delegateTool?.description).toContain('not tool-call arguments') expect(properties).not.toHaveProperty('model') expect(properties).not.toHaveProperty('providerId') expect(properties).toHaveProperty('profile') expect(properties).not.toHaveProperty('custom_agent') + expect(delegateTool?.inputSchema.required).toEqual(['prompt']) const customRuntime = { enabled: () => true, - listProfiles: () => [], useExistingAgents: false, defaultToolPolicy: 'inherit' } as unknown as DelegationRuntime - const customTool = buildDelegationToolProviders(customRuntime)[0]?.tools[0] + const customTools = buildDelegationToolProviders(customRuntime)[0]?.tools ?? [] + const customTool = customTools[0] const customModeProperties = customTool?.inputSchema.properties as Record | undefined + expect(customTools.map((tool) => tool.name)).toEqual(['delegate_task', 'list_subagent_profiles']) expect(customModeProperties).not.toHaveProperty('profile') expect(customModeProperties?.custom_agent?.description).toContain('always inherits the current turn model/provider/reasoning strength') expect(customTool?.inputSchema.required).toEqual(['prompt', 'custom_agent']) @@ -37,6 +40,174 @@ describe('delegate_task observability output', () => { expect(customProperties).not.toHaveProperty('reasoning_effort') }) + it('returns only the custom capability when reusable profiles are disabled', async () => { + const listRoutingProfiles = vi.fn() + const runtime = { + enabled: () => true, + useExistingAgents: false, + defaultToolPolicy: 'inherit', + listRoutingProfiles + } as unknown as DelegationRuntime + const tool = buildDelegationToolProviders(runtime)[0]?.tools + .find((candidate) => candidate.name === 'list_subagent_profiles') + + const result = await tool!.execute({}, context()) + + expect(result.output).toMatchObject({ + mode: 'custom-only', + surface: 'code', + profileCount: 0, + nextOffset: null, + profiles: [], + customAgent: { + id: 'custom', + argument: 'custom_agent', + lifetime: 'one-run', + requiredFields: ['name', 'description', 'system_prompt'] + } + }) + expect(listRoutingProfiles).not.toHaveBeenCalled() + }) + + it('keeps read-only discovery visible in plan mode without advertising child execution', async () => { + const runtime = { + enabled: () => true, + useExistingAgents: true, + defaultToolPolicy: 'inherit' + } as unknown as DelegationRuntime + const host = new LocalToolHost({ + registry: new CapabilityRegistry(buildDelegationToolProviders(runtime)) + }) + + const tools = await host.listTools({ ...context(), threadMode: 'plan' }) + + expect(tools.map((tool) => tool.name)).toEqual(['list_subagent_profiles']) + expect(tools[0]?.sideEffect).toBe('read-only') + }) + + it('lists a bounded page from the current workspace and surface without leaking profile instructions', async () => { + const listRoutingProfiles = vi.fn(async () => [ + { + kind: 'profile' as const, + id: 'alpha', + source: 'configured' as const, + profile: { + name: 'A'.repeat(300), + description: 'D'.repeat(1_200), + toolPolicy: 'inherit' as const, + systemPrompt: 'secret system prompt', + promptPreamble: 'secret preamble', + model: 'secret-model', + providerId: 'secret-provider' + } + }, + { + kind: 'profile' as const, + id: 'workspace-reviewer', + source: 'workspace' as const, + profile: { + name: 'Workspace Reviewer', + description: 'Reviews the active design workspace.', + toolPolicy: 'readOnly' as const, + blockedTools: ['write'] + } + } + ]) + const runtime = { + enabled: () => true, + useExistingAgents: true, + defaultProfileName: 'general', + defaultToolPolicy: 'inherit', + listRoutingProfiles + } as unknown as DelegationRuntime + const tool = buildDelegationToolProviders(runtime)[0]?.tools + .find((candidate) => candidate.name === 'list_subagent_profiles') + + const result = await tool!.execute({ offset: 0, limit: 1 }, { + ...context(), + workspace: '/workspace/design', + agentSurface: 'design' + }) + const output = result.output as { + profiles: Array<{ name: string; description: string }> + } + + expect(listRoutingProfiles).toHaveBeenCalledWith('/workspace/design', 'design') + expect(result.output).toMatchObject({ + mode: 'profiles-only', + surface: 'design', + profileCount: 2, + offset: 0, + limit: 1, + nextOffset: 1, + profiles: [{ + id: 'alpha', + toolPolicy: 'inherit', + access: expect.stringContaining('parent') + }] + }) + expect(result.output).not.toHaveProperty('customAgent') + expect(output.profiles[0]?.name).toHaveLength(256) + expect(output.profiles[0]?.description).toHaveLength(1_000) + expect(JSON.stringify(result.output)).not.toContain('secret system prompt') + expect(JSON.stringify(result.output)).not.toContain('secret preamble') + expect(JSON.stringify(result.output)).not.toContain('secret-model') + expect(JSON.stringify(result.output)).not.toContain('secret-provider') + expect(JSON.stringify(result.output)).not.toContain('blockedTools') + }) + + it('runs an explicit custom agent in custom-only mode without invoking catalog routing', async () => { + const listRoutingProfiles = vi.fn() + const runChild = vi.fn(async (input: Parameters[0]) => ({ + id: 'child_custom', + parentThreadId: input.parentThreadId, + parentTurnId: input.parentTurnId, + prompt: input.prompt, + profile: input.inlineProfile?.id, + profileSource: 'custom' as const, + profileSnapshot: input.inlineProfile?.profile, + toolPolicy: 'readOnly' as const, + status: 'completed' as const, + summary: 'Custom review complete.', + usage: { promptTokens: 3, completionTokens: 2, totalTokens: 5 }, + returnFormat: 'summary' as const, + createdAt: '2026-07-25T00:00:00.000Z', + updatedAt: '2026-07-25T00:00:01.000Z' + })) + const runtime = { + enabled: () => true, + useExistingAgents: false, + defaultToolPolicy: 'inherit', + listRoutingProfiles, + runChild + } as unknown as DelegationRuntime + const tool = buildDelegationToolProviders(runtime)[0]?.tools + .find((candidate) => candidate.name === 'delegate_task') + + const result = await tool!.execute({ + prompt: 'Review the IPC boundary', + custom_agent: { + name: 'IPC Reviewer', + description: 'Reviews IPC boundaries.', + system_prompt: 'Review IPC boundaries and return concrete evidence.', + tool_policy: 'readOnly' + } + }, context()) + + expect(result.isError).toBe(false) + expect(listRoutingProfiles).not.toHaveBeenCalled() + expect(runChild).toHaveBeenCalledWith(expect.objectContaining({ + inlineProfile: expect.objectContaining({ + id: 'custom:ipc-reviewer', + source: 'custom' + }), + routing: expect.objectContaining({ + method: 'explicit-custom', + selectedKind: 'custom' + }) + })) + }) + it('includes the effective model and snapshotted profile name in live and final output', async () => { const runChild = vi.fn(async (input: Parameters[0]) => { const metadata = { @@ -131,11 +302,10 @@ describe('delegate_task observability output', () => { expect(childInput).not.toHaveProperty('providerId') }) - it('rejects stale arguments that cross the configured delegation mode', async () => { + it('rejects custom arguments in existing-profile mode and stale arguments that cross custom-only mode', async () => { const runChild = vi.fn() const existingRuntime = { enabled: () => true, - listProfiles: () => [], useExistingAgents: true, defaultToolPolicy: 'inherit', runChild @@ -152,10 +322,21 @@ describe('delegate_task observability output', () => { isError: true, output: { error: expect.stringContaining('turned on') } }) + await expect(existingTool.execute({ + prompt: 'Review the change', + profile: 'reviewer', + custom_agent: { + name: 'Reviewer', + description: 'Reviews changes.', + system_prompt: 'Review the change.' + } + }, context())).resolves.toMatchObject({ + isError: true, + output: { error: expect.stringContaining('custom_agent is unavailable') } + }) const customRuntime = { enabled: () => true, - listProfiles: () => [], useExistingAgents: false, defaultToolPolicy: 'inherit', runChild diff --git a/kun/src/adapters/tool/delegation-tool-provider.ts b/kun/src/adapters/tool/delegation-tool-provider.ts index 11812dc0a..d74851747 100644 --- a/kun/src/adapters/tool/delegation-tool-provider.ts +++ b/kun/src/adapters/tool/delegation-tool-provider.ts @@ -22,18 +22,22 @@ type InlineProfile = { source?: 'builtin' | 'configured' | 'workspace' | 'custom' | 'generated' } +const DEFAULT_PROFILE_PAGE_LIMIT = 50 +const MAX_PROFILE_PAGE_LIMIT = 50 +const MAX_PROFILE_NAME_LENGTH = 256 +const MAX_PROFILE_DESCRIPTION_LENGTH = 1_000 + export function buildDelegationToolProviders( runtime: DelegationRuntime | undefined, router?: SubagentRouter ): CapabilityToolProvider[] { if (!runtime?.enabled()) return [] - const profiles = runtime.listProfiles().filter((profile) => profile.mode !== 'primary') const useExistingAgents = runtime.useExistingAgents !== false const modeProperties = useExistingAgents ? { profile: { type: 'string', - description: 'Optional exact existing agent profile id. Omit it to route over the configured agent catalog.' + description: 'Optional exact reusable profile id returned by list_subagent_profiles. Omit it to route over the effective catalog.' } } : { custom_agent: customAgentSchema() } @@ -46,7 +50,7 @@ export function buildDelegationToolProviders( tools: [ LocalToolHost.defineTool({ name: 'delegate_task', - description: buildDelegateTaskDescription(runtime, profiles.length), + description: buildDelegateTaskDescription(runtime), inputSchema: { type: 'object', properties: { @@ -69,6 +73,9 @@ export function buildDelegationToolProviders( let routing: ChildRoutingMetadata | undefined const agentSurface = context.agentSurface ?? 'code' + if (useExistingAgents && customAgentSupplied) { + return toolError('custom_agent is unavailable while "Use existing agents" is turned on; select a reusable profile or omit profile for automatic routing') + } if (!useExistingAgents) { if (requestedProfile) { return toolError('profile is unavailable while "Use existing agents" is turned off; define custom_agent instead') @@ -76,19 +83,17 @@ export function buildDelegationToolProviders( if (!customAgentSupplied) { return toolError('custom_agent is required while "Use existing agents" is turned off') } + } + if (!useExistingAgents && customAgentSupplied) { const customDefinition = parseCustomAgent(args.custom_agent) if (customDefinition instanceof Error) return toolError(customDefinition.message) - if (!customDefinition) return toolError('custom_agent is required while "Use existing agents" is turned off') + if (!customDefinition) return toolError('custom_agent is required') inlineProfile = { id: customSubagentProfileId(customDefinition.name), profile: customSubagentProfile(customDefinition), source: 'custom' } routing = explicitCustomMetadata(inlineProfile, agentSurface) - } else { - if (customAgentSupplied) { - return toolError('custom_agent is unavailable while "Use existing agents" is turned on; select profile or omit it for automatic routing') - } } if (useExistingAgents && requestedProfile) { @@ -105,7 +110,7 @@ export function buildDelegationToolProviders( agentSurface, candidates: [] } - } else if (useExistingAgents) { + } else if (useExistingAgents && !inlineProfile) { const documents = await runtime.listRoutingProfiles(common.workspace, agentSurface) const route = router ? await router.route({ @@ -142,6 +147,74 @@ export function buildDelegationToolProviders( ...(routing ? { routing } : {}) }) } + }), + LocalToolHost.defineTool({ + name: 'list_subagent_profiles', + description: buildListSubagentProfilesDescription(runtime), + inputSchema: { + type: 'object', + properties: { + offset: { + type: 'integer', + minimum: 0, + description: 'Zero-based reusable-profile offset.' + }, + limit: { + type: 'integer', + minimum: 1, + maximum: MAX_PROFILE_PAGE_LIMIT, + description: `Reusable profiles to return, from 1 to ${MAX_PROFILE_PAGE_LIMIT}.` + } + }, + additionalProperties: false + }, + policy: 'auto', + sideEffect: 'read-only', + execute: async (args, context) => { + const offset = nonnegativeInteger(args.offset, 0) + const limit = boundedPositiveInteger( + args.limit, + DEFAULT_PROFILE_PAGE_LIMIT, + MAX_PROFILE_PAGE_LIMIT + ) + const agentSurface = context.agentSurface ?? 'code' + const documents = useExistingAgents + ? await runtime.listRoutingProfiles(context.workspace, agentSurface) + : [] + const page = documents.slice(offset, offset + limit) + const nextOffset = offset + page.length < documents.length + ? offset + page.length + : null + return { + output: { + mode: useExistingAgents ? 'profiles-only' : 'custom-only', + surface: agentSurface, + ...(!useExistingAgents ? { customAgent: customAgentCapability() } : {}), + profileCount: documents.length, + offset, + limit, + nextOffset, + profiles: page.map((document) => ({ + id: document.id, + name: boundedText( + document.profile.name ?? document.id, + MAX_PROFILE_NAME_LENGTH + ), + description: boundedText( + document.profile.description ?? 'No description provided.', + MAX_PROFILE_DESCRIPTION_LENGTH + ), + toolPolicy: document.profile.toolPolicy, + access: document.profile.toolPolicy === 'readOnly' + ? 'Read-only investigation; cannot modify the workspace.' + : 'May use only tools allowed by the parent capability and approval boundary.' + })), + guidance: useExistingAgents + ? 'Pass an exact id as delegate_task.profile, or omit profile for automatic routing over the effective catalog.' + : 'Reusable profiles are disabled. Define a one-run role with delegate_task.custom_agent.' + } + } + } }) ] }] @@ -163,6 +236,38 @@ function customAgentSchema(): Record { } } +function customAgentCapability(): Record { + return { + id: 'custom', + name: 'Custom Subagent', + description: 'Define a focused one-run child role directly in delegate_task.custom_agent when no reusable profile is the right fit.', + argument: 'custom_agent', + lifetime: 'one-run', + requiredFields: ['name', 'description', 'system_prompt'], + optionalFields: ['tool_policy', 'blocked_tools'], + toolPolicyOptions: [ + { + value: 'readOnly', + description: 'Restrict the child to host-approved read-only investigation tools.' + }, + { + value: 'inherit', + description: 'Allow only the subset of parent-authorized tools that remains inside the child security boundary.' + } + ], + hostControlled: [ + 'model', + 'provider', + 'reasoning', + 'approval', + 'sandbox', + 'concurrency', + 'budget', + 'timeout' + ] + } +} + function parseCommonArgs(args: Record, context: ToolHostContext): { prompt: string workspace: string @@ -380,9 +485,9 @@ function routingToolOutput(routing: ChildRoutingMetadata): Record { } ]) }) + + it('preserves Cursor delegated question prompts and choices', async () => { + const host = new LocalToolHost({ tools: [userInputTool] }) + const captured: Parameters>[0][] = [] + const context = { + threadId: 'thread_cursor_input', + turnId: 'turn_cursor_input', + workspace: '/tmp/workspace', + approvalPolicy: 'auto', + sandboxMode: 'workspace-write', + abortSignal: new AbortController().signal, + awaitApproval: vi.fn(async () => 'allow' as const), + awaitUserInput: vi.fn(async (input) => { + captured.push(input) + return { status: 'submitted' as const, answers: [] } + }) + } satisfies ToolHostContext + + await host.execute( + { + callId: 'call_cursor_input', + toolName: 'user_input', + arguments: { + questions: [{ + id: 'next_action', + prompt: 'Release review finished. What should I do next?', + options: [ + { id: 'fix', label: 'Fix blockers' }, + { id: 'done', label: 'Review only' } + ] + }] + } + }, + context + ) + + expect(captured[0]).toMatchObject({ + prompt: 'Release review finished. What should I do next?', + questions: [{ + id: 'next_action', + question: 'Release review finished. What should I do next?', + options: [ + { label: 'Fix blockers', description: '' }, + { label: 'Review only', description: '' } + ] + }] + }) + }) + + it('rejects empty user_input calls instead of prompting with a fallback', async () => { + const host = new LocalToolHost({ tools: [userInputTool] }) + const awaitUserInput = vi.fn(async () => ({ status: 'submitted' as const, answers: [] })) + const context = { + threadId: 'thread_empty_input', + turnId: 'turn_empty_input', + workspace: '/tmp/workspace', + approvalPolicy: 'auto', + sandboxMode: 'workspace-write', + abortSignal: new AbortController().signal, + awaitApproval: vi.fn(async () => 'allow' as const), + awaitUserInput + } satisfies ToolHostContext + + const result = await host.execute( + { + callId: 'call_empty_input', + toolName: 'user_input', + arguments: {} + }, + context + ) + + expect(awaitUserInput).not.toHaveBeenCalled() + expect(result.item).toMatchObject({ + kind: 'tool_result', + toolName: 'user_input', + isError: true, + output: { + error: 'user_input requires a non-empty prompt, question, message, or questions[].question' + } + }) + }) + + it('rejects user_input questions that only include options without text', async () => { + const host = new LocalToolHost({ tools: [userInputTool] }) + const awaitUserInput = vi.fn(async () => ({ status: 'submitted' as const, answers: [] })) + const context = { + threadId: 'thread_blank_questions', + turnId: 'turn_blank_questions', + workspace: '/tmp/workspace', + approvalPolicy: 'auto', + sandboxMode: 'workspace-write', + abortSignal: new AbortController().signal, + awaitApproval: vi.fn(async () => 'allow' as const), + awaitUserInput + } satisfies ToolHostContext + + const result = await host.execute( + { + callId: 'call_blank_questions', + toolName: 'user_input', + arguments: { + questions: [{ id: 'next', options: ['Continue', 'Stop'] }] + } + }, + context + ) + + expect(awaitUserInput).not.toHaveBeenCalled() + expect(result.item).toMatchObject({ + kind: 'tool_result', + isError: true + }) + }) }) diff --git a/kun/src/adapters/tool/local-tool-host.ts b/kun/src/adapters/tool/local-tool-host.ts index f92185d74..cb36d24ea 100644 --- a/kun/src/adapters/tool/local-tool-host.ts +++ b/kun/src/adapters/tool/local-tool-host.ts @@ -43,11 +43,15 @@ import { * A single registered tool. Tools are pure functions that observe the * abort signal and may be guarded by an approval policy. */ +export type ToolSideEffect = 'read-only' | 'unknown' + export type LocalTool = { name: string description: string inputSchema: Record toolKind: 'tool_call' | 'command_execution' | 'file_change' + /** Host-authored side-effect classification. Unknown is denied in Plan mode. */ + sideEffect?: ToolSideEffect /** * Tool policy. `auto` runs the tool without asking. `on-request` and * `suggest` always ask the user. `never` blocks the tool. `untrusted` @@ -511,6 +515,7 @@ export class LocalToolHost implements ToolHost { description: tool.description, inputSchema: tool.inputSchema, toolKind: tool.toolKind ?? 'tool_call', + ...(tool.sideEffect ? { sideEffect: tool.sideEffect } : {}), execute: tool.execute, ...(tool.shouldAdvertise ? { shouldAdvertise: tool.shouldAdvertise } : {}), ...(tool.requiresExplicitApproval ? { requiresExplicitApproval: true } : {}), @@ -605,7 +610,8 @@ function createUserInputTool(name: string): LocalTool { } return LocalToolHost.defineTool({ name, - description: 'Ask the GUI user a structured question and wait for the answer.', + description: + 'Ask the GUI user a structured question and wait for the answer. Requires a non-empty prompt, question, message, or questions[].question (prompt/message aliases allowed).', toolKind: 'tool_call', inputSchema: { type: 'object', @@ -642,6 +648,14 @@ function createUserInputTool(name: string): LocalTool { header: { type: 'string' }, id: { type: 'string' }, question: { type: 'string' }, + prompt: { + type: 'string', + description: 'Alias for question used by delegated SDK tool callers.' + }, + message: { + type: 'string', + description: 'Alias for question used by delegated SDK tool callers.' + }, options: { type: 'array', items: optionSchema @@ -658,8 +672,7 @@ function createUserInputTool(name: string): LocalTool { type: 'integer', minimum: 1 } - }, - required: ['question'] + } } } }, @@ -675,8 +688,18 @@ function createUserInputTool(name: string): LocalTool { } const inputId = `in_${Math.random().toString(36).slice(2, 10)}` const itemId = `item_${inputId}` - const prompt = String(args.prompt ?? args.question ?? args.message ?? 'Input requested') - const questions = normalizeUserInputQuestions(args, inputId, prompt) + const explicitPrompt = firstNonEmptyString(args.prompt, args.question, args.message) + const questions = normalizeUserInputQuestions(args, inputId, explicitPrompt) + if (questions.length === 0) { + return { + output: { + error: + 'user_input requires a non-empty prompt, question, message, or questions[].question' + }, + isError: true + } + } + const prompt = explicitPrompt ?? questions[0]!.question const resolution = await context.awaitUserInput({ id: inputId, itemId, prompt, questions }) return { output: resolution, @@ -699,7 +722,7 @@ export const defaultLocalTools: LocalTool[] = [ function normalizeUserInputQuestions( args: Record, fallbackId: string, - fallbackPrompt: string + fallbackPrompt: string | undefined ): UserInputQuestion[] { const rawQuestions = Array.isArray(args.questions) ? args.questions : null if (rawQuestions && rawQuestions.length > 0) { @@ -708,6 +731,7 @@ function normalizeUserInputQuestions( .filter((question): question is UserInputQuestion => question !== null) if (questions.length > 0) return questions } + if (!fallbackPrompt) return [] const options = Array.isArray(args.options) ? args.options .map((option) => normalizeUserInputOption(option)) @@ -731,9 +755,7 @@ function normalizeUserInputQuestion( ): UserInputQuestion | null { if (!value || typeof value !== 'object') return null const raw = value as Record - const question = typeof raw.question === 'string' && raw.question.trim() - ? raw.question.trim() - : null + const question = firstNonEmptyString(raw.question, raw.prompt, raw.message) if (!question) return null const options = Array.isArray(raw.options) ? raw.options @@ -749,6 +771,15 @@ function normalizeUserInputQuestion( } } +function firstNonEmptyString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value !== 'string') continue + const normalized = value.trim() + if (normalized) return normalized + } + return undefined +} + function normalizeUserInputSelection( raw: Record, optionCount: number diff --git a/kun/src/adapters/tool/lsp-client.test.ts b/kun/src/adapters/tool/lsp-client.test.ts index a5d21f298..8081b3967 100644 --- a/kun/src/adapters/tool/lsp-client.test.ts +++ b/kun/src/adapters/tool/lsp-client.test.ts @@ -1,10 +1,11 @@ import { EventEmitter } from 'node:events' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { accessMock, spawnMock } = vi.hoisted(() => ({ +const { accessMock, existsSyncMock, spawnMock } = vi.hoisted(() => ({ accessMock: vi.fn(), + existsSyncMock: vi.fn(), spawnMock: vi.fn() })) @@ -12,6 +13,14 @@ vi.mock('node:child_process', () => ({ spawn: spawnMock })) +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + existsSync: existsSyncMock + } +}) + vi.mock('node:fs/promises', async () => { const actual = await vi.importActual('node:fs/promises') return { @@ -61,15 +70,38 @@ function emitJsonRpc(proc: MockProcess, message: Record): void proc.stdout.emit('data', Buffer.from(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`, 'utf8')) } +beforeEach(() => { + existsSyncMock.mockReturnValue(false) +}) + afterEach(() => { shutdownAllLspSessions() spawnMock.mockReset() accessMock.mockReset() + existsSyncMock.mockReset() vi.restoreAllMocks() vi.useRealTimers() }) describe('resolveServerCommand', () => { + it('prefers the TypeScript language server bundled under Kun', async () => { + existsSyncMock.mockReturnValue(true) + + const resolved = await resolveServerCommand('/workspace', 'typescript') + + expect(resolved).toEqual({ + command: process.execPath, + args: [ + expect.stringMatching( + /kun[\\/]node_modules[\\/]typescript-language-server[\\/]lib[\\/]cli\.mjs$/ + ), + '--stdio' + ], + env: { ELECTRON_RUN_AS_NODE: '1' } + }) + expect(spawnMock).not.toHaveBeenCalled() + }) + it('uses where on Windows when looking up a server on PATH', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') @@ -92,6 +124,41 @@ describe('resolveServerCommand', () => { }) }) +describe('bundled LSP process', () => { + it('launches the bundled server with Electron Node mode preserved', async () => { + existsSyncMock.mockReturnValue(true) + let spawnOptions: { env?: NodeJS.ProcessEnv } | undefined + + spawnMock.mockImplementation( + (command: string, args: string[], options: { env?: NodeJS.ProcessEnv }) => { + expect(command).toBe(process.execPath) + expect(args).toEqual([ + expect.stringMatching( + /kun[\\/]node_modules[\\/]typescript-language-server[\\/]lib[\\/]cli\.mjs$/ + ), + '--stdio' + ]) + spawnOptions = options + return createMockProcess((chunk, proc) => { + const request = parseJsonRpc(chunk) + if (request.method === 'initialize') { + queueMicrotask(() => emitJsonRpc(proc, { + jsonrpc: '2.0', + id: request.id, + result: {} + })) + } + }) + } + ) + + const session = await acquireLspSession('/workspace/bundled', 'typescript') + + expect(spawnOptions?.env).toMatchObject({ ELECTRON_RUN_AS_NODE: '1' }) + releaseLspSession('/workspace/bundled', 'typescript') + }) +}) + describe('LSP session cooldown', () => { it('enters cooldown after an initialize failure and retries after the cooldown expires', async () => { vi.useFakeTimers() diff --git a/kun/src/adapters/tool/lsp-client.ts b/kun/src/adapters/tool/lsp-client.ts index a400b1581..dca5647c4 100644 --- a/kun/src/adapters/tool/lsp-client.ts +++ b/kun/src/adapters/tool/lsp-client.ts @@ -415,7 +415,10 @@ async function createSession(workspaceRoot: string, serverKey: string): Promise< const proc = spawn(cmd.command, cmd.args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: workspaceRoot, - env: shellSpawnEnv(), + env: { + ...shellSpawnEnv(), + ...cmd.env + }, windowsHide: true }) diff --git a/kun/src/adapters/tool/lsp-servers.ts b/kun/src/adapters/tool/lsp-servers.ts index 0b0f4051c..483044e41 100644 --- a/kun/src/adapters/tool/lsp-servers.ts +++ b/kun/src/adapters/tool/lsp-servers.ts @@ -1,11 +1,20 @@ import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import { shellSpawnEnv } from './builtin-tool-utils.js' const SERVER_PROBE_TIMEOUT = 3_000 +const KUN_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..') +const BUNDLED_TYPESCRIPT_LANGUAGE_SERVER_CLI = resolve( + KUN_PACKAGE_ROOT, + 'node_modules/typescript-language-server/lib/cli.mjs' +) export type LspServerCommand = { command: string args: string[] + env?: NodeJS.ProcessEnv } export interface LanguageServerDef { @@ -34,8 +43,16 @@ function registerDefaultLanguageServers(): void { key: 'typescript', displayName: 'TypeScript/JavaScript', extensions: ['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs', '.cts', '.cjs'], - installHint: 'Install with: npm install -g typescript-language-server typescript', + installHint: + 'Reinstall Kun to restore its bundled TypeScript language server, or install a compatible typescript-language-server on PATH.', resolveCommand: async () => { + if (existsSync(BUNDLED_TYPESCRIPT_LANGUAGE_SERVER_CLI)) { + return { + command: process.execPath, + args: [BUNDLED_TYPESCRIPT_LANGUAGE_SERVER_CLI, '--stdio'], + env: { ELECTRON_RUN_AS_NODE: '1' } + } + } return resolvePathOnly('typescript-language-server', ['--stdio']) }, languageIdForFile: (filePath) => { diff --git a/kun/src/adapters/tool/mcp-facade-provider.ts b/kun/src/adapters/tool/mcp-facade-provider.ts index c1950ba7a..9e46f3604 100644 --- a/kun/src/adapters/tool/mcp-facade-provider.ts +++ b/kun/src/adapters/tool/mcp-facade-provider.ts @@ -49,6 +49,7 @@ function createListResourcesTool(connected: McpFacadeConnectionState[]): LocalTo // sensitive metadata, so facade RPCs use the same approval boundary as // direct MCP calls rather than treating server annotations as authority. policy: 'on-request', + sideEffect: 'read-only', toolKind: 'command_execution', inputSchema: { type: 'object', @@ -75,6 +76,7 @@ function createReadResourceTool(connected: McpFacadeConnectionState[]): LocalToo name: 'mcp_read_resource', description: 'Read one MCP resource from a connected MCP server.', policy: 'on-request', + sideEffect: 'read-only', toolKind: 'command_execution', inputSchema: { type: 'object', @@ -101,6 +103,7 @@ function createListResourceTemplatesTool(connected: McpFacadeConnectionState[]): name: 'mcp_list_resource_templates', description: 'List MCP resource templates exposed by currently connected MCP servers.', policy: 'on-request', + sideEffect: 'read-only', toolKind: 'command_execution', inputSchema: { type: 'object', @@ -127,6 +130,7 @@ function createListPromptsTool(connected: McpFacadeConnectionState[]): LocalTool name: 'mcp_list_prompts', description: 'List MCP prompts exposed by currently connected MCP servers.', policy: 'on-request', + sideEffect: 'read-only', toolKind: 'command_execution', inputSchema: { type: 'object', @@ -153,6 +157,7 @@ function createGetPromptTool(connected: McpFacadeConnectionState[]): LocalTool { name: 'mcp_get_prompt', description: 'Get one MCP prompt from a connected MCP server.', policy: 'on-request', + sideEffect: 'read-only', toolKind: 'command_execution', inputSchema: { type: 'object', diff --git a/kun/src/adapters/tool/mcp-tool-provider.test.ts b/kun/src/adapters/tool/mcp-tool-provider.test.ts index b188b64ff..4d897e29b 100644 --- a/kun/src/adapters/tool/mcp-tool-provider.test.ts +++ b/kun/src/adapters/tool/mcp-tool-provider.test.ts @@ -77,6 +77,60 @@ const descriptor: McpToolDescriptor = { } describe('mcp tool provider reliability', () => { + it('uses only host configuration, not remote read-only annotations, for Plan access', async () => { + const client = new MockMcpClient([descriptor], vi.fn(async () => ({ ok: true }))) + const untrustedHint = await buildMcpToolProviders(config, { + clientFactory: vi.fn(async () => client) + }) + const hintedTool = untrustedHint.providers + .flatMap((provider) => provider.tools) + .find((tool) => tool.name === 'mcp_docs_lookup') + expect(hintedTool?.sideEffect).toBeUndefined() + + const hostConfigured = McpCapabilityConfig.parse({ + enabled: true, + servers: { docs: { ...server, planModeReadOnlyTools: ['lookup'] } }, + search: { enabled: false } + }) + const configured = await buildMcpToolProviders(hostConfigured, { + clientFactory: vi.fn(async () => new MockMcpClient([descriptor], vi.fn(async () => ({ ok: true })))) + }) + const configuredTool = configured.providers + .flatMap((provider) => provider.tools) + .find((tool) => tool.name === 'mcp_docs_lookup') + expect(configuredTool?.sideEffect).toBe('read-only') + }) + + it('gates the search-mode read-only call gateway with host configuration', async () => { + const callTool = vi.fn(async () => ({ rows: [{ id: 1 }] })) + const configured = McpCapabilityConfig.parse({ + enabled: true, + servers: { docs: { ...server, planModeReadOnlyTools: ['lookup'] } }, + search: { enabled: true, mode: 'search', topKDefault: 5, topKMax: 10, minScore: 0.15 } + }) + const built = await buildMcpToolProviders(configured, { + clientFactory: vi.fn(async () => new MockMcpClient([descriptor], callTool)) + }) + const gateway = built.providers + .flatMap((provider) => provider.tools) + .find((tool) => tool.name === 'mcp_read_only_call') + + expect(gateway?.sideEffect).toBe('read-only') + await expect(gateway!.execute({ toolId: 'mcp_docs_lookup', arguments: {} }, context)) + .resolves.toMatchObject({ output: { result: { rows: [{ id: 1 }] } } }) + expect(callTool).toHaveBeenCalledTimes(1) + + const unconfigured = await buildMcpToolProviders(searchConfig, { + clientFactory: vi.fn(async () => new MockMcpClient([descriptor], callTool)) + }) + const blockedGateway = unconfigured.providers + .flatMap((provider) => provider.tools) + .find((tool) => tool.name === 'mcp_read_only_call') + await expect(blockedGateway!.execute({ toolId: 'mcp_docs_lookup', arguments: {} }, context)) + .resolves.toMatchObject({ isError: true, output: { error: expect.stringContaining('not host-approved') } }) + expect(callTool).toHaveBeenCalledTimes(1) + }) + it('does not replay concurrent MCP calls based on server read-only annotations', async () => { const first = new MockMcpClient([descriptor], vi.fn(async () => { throw new Error('socket connection reset') diff --git a/kun/src/adapters/tool/mcp-tool-provider.ts b/kun/src/adapters/tool/mcp-tool-provider.ts index 1caa02af1..225cf6e5d 100644 --- a/kun/src/adapters/tool/mcp-tool-provider.ts +++ b/kun/src/adapters/tool/mcp-tool-provider.ts @@ -621,6 +621,9 @@ function createMcpLocalTool( // annotations are unauthenticated metadata, so it must not bypass the // host command sandbox by masquerading as a harmless tool call. toolKind: 'command_execution', + ...(state.server.planModeReadOnlyTools?.includes(descriptor.name) + ? { sideEffect: 'read-only' as const } + : {}), policy: policyFromAnnotations(descriptor.annotations), shouldAdvertise: (context: ToolHostContext) => canUseMcpServer(state.server, context.workspace), execute: async (args, context) => { diff --git a/kun/src/adapters/tool/mcp-tool-search.ts b/kun/src/adapters/tool/mcp-tool-search.ts index 7cdee0afb..5ccab6363 100644 --- a/kun/src/adapters/tool/mcp-tool-search.ts +++ b/kun/src/adapters/tool/mcp-tool-search.ts @@ -14,6 +14,7 @@ import { const MCP_SEARCH_TOOL_NAME = 'mcp_search' const MCP_DESCRIBE_TOOL_NAME = 'mcp_describe' const MCP_CALL_TOOL_NAME = 'mcp_call' +const MCP_READ_ONLY_CALL_TOOL_NAME = 'mcp_read_only_call' const MCP_REFRESH_CATALOG_TOOL_NAME = 'mcp_refresh_catalog' const MAX_FROZEN_MCP_CATALOGS = 256 @@ -245,6 +246,7 @@ function createMcpSearchTools( required: ['query'] }, policy: 'auto', + sideEffect: 'read-only', execute: async (args, context) => { const query = stringArg(args.query) if (!query) return { output: { error: 'query is required' }, isError: true } @@ -277,6 +279,7 @@ function createMcpSearchTools( required: ['toolId'] }, policy: 'auto', + sideEffect: 'read-only', execute: async (args, context) => { const toolId = stringArg(args.toolId) const record = resolveAvailableRecord(options, catalog, context, toolId) @@ -284,6 +287,46 @@ function createMcpSearchTools( return { output: describeRecord(record) } } }), + LocalToolHost.defineTool({ + name: MCP_READ_ONLY_CALL_TOOL_NAME, + description: 'Call a host-approved read-only MCP tool by canonical tool id. Available in Plan mode; rejects tools not listed in the server planModeReadOnlyTools configuration.', + inputSchema: { + type: 'object', + properties: { + toolId: { type: 'string', description: 'Canonical MCP tool id in the form mcp__.' }, + arguments: { type: 'object', description: 'Arguments matching the MCP tool input schema.' } + }, + required: ['toolId', 'arguments'] + }, + policy: 'on-request', + sideEffect: 'read-only', + toolKind: 'command_execution', + execute: async (args, context) => { + const toolId = stringArg(args.toolId) + const record = resolveAvailableRecord(options, catalog, context, toolId) + if (!record) return { output: { error: `unknown MCP tool: ${toolId}` }, isError: true } + if (!record.server.planModeReadOnlyTools?.includes(record.descriptor.name)) { + return { + output: { error: `MCP tool ${record.toolId} is not host-approved as read-only` }, + isError: true + } + } + const callArgs = objectArg(args.arguments) + const result = await record.client.callTool( + { name: record.descriptor.name, arguments: callArgs }, + { signal: context.abortSignal, timeout: record.server.timeoutMs } + ) + return { + output: { + serverId: record.serverId, + toolName: record.descriptor.name, + toolId: record.toolId, + result + }, + isError: typeof result === 'object' && result !== null && (result as { isError?: boolean }).isError === true + } + } + }), LocalToolHost.defineTool({ name: MCP_CALL_TOOL_NAME, description: 'Call a connected MCP tool by canonical tool id with JSON arguments.', diff --git a/kun/src/adapters/tool/office-cli-tool-provider.test.ts b/kun/src/adapters/tool/office-cli-tool-provider.test.ts new file mode 100644 index 000000000..4a2095c93 --- /dev/null +++ b/kun/src/adapters/tool/office-cli-tool-provider.test.ts @@ -0,0 +1,246 @@ +import { createHash } from 'node:crypto' +import { link, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ToolHostContext } from '../../ports/tool-host.js' +import { buildOfficeCliLocalTools } from './office-cli-tool-provider.js' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +function context(workspace: string): ToolHostContext { + return { + threadId: 'thread_office', + turnId: 'turn_office', + workspace, + approvalPolicy: 'auto', + sandboxMode: 'workspace-write', + abortSignal: new AbortController().signal, + awaitApproval: async () => 'allow' + } +} + +function sha256(value: Buffer | string): string { + return createHash('sha256').update(value).digest('hex') +} + +describe('OfficeCLI controlled tools', () => { + it('inspects a supported document without exposing an arbitrary command surface', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'kun-office-tools-')) + roots.push(workspace) + const filePath = join(workspace, 'book.xlsx') + await writeFile(filePath, 'source workbook') + const run = vi.fn(async () => ({ + stdout: '{"sheets":2}', + stderr: '', + exitCode: 0 + })) + const inspect = buildOfficeCliLocalTools({ run })[0]! + + const result = await inspect.execute({ + path: 'book.xlsx', + action: 'summary' + }, context(workspace)) + + expect(result.isError).not.toBe(true) + expect(result.output).toMatchObject({ + path: filePath, + relative_path: 'book.xlsx', + format: 'xlsx', + source_sha256: sha256('source workbook'), + action: 'summary', + result: { sheets: 2 } + }) + expect(run).toHaveBeenCalledWith( + ['view', filePath, 'stats', '--json'], + expect.any(AbortSignal) + ) + expect(inspect.inputSchema).not.toHaveProperty('properties.command') + }) + + it('renders scoped previews through the screenshot surface without passing a raw command', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'kun-office-tools-')) + roots.push(workspace) + const filePath = join(workspace, 'book.xlsx') + await writeFile(filePath, 'source workbook') + const run = vi.fn(async (args: readonly string[]) => { + const outputIndex = args.indexOf('--out') + if (outputIndex >= 0) await writeFile(args[outputIndex + 1]!, Buffer.from('PNG preview')) + return { stdout: '', stderr: '', exitCode: 0 } + }) + const preview = buildOfficeCliLocalTools({ run })[2]! + + const result = await preview.execute({ + path: 'book.xlsx', + sheet: 'Sheet1', + range: 'A1:C8' + }, context(workspace)) + + expect(result.isError).not.toBe(true) + expect(result.output).toMatchObject({ + kind: 'image', + path: filePath, + mime_type: 'image/png', + data_base64: Buffer.from('PNG preview').toString('base64') + }) + const args = run.mock.calls[0]?.[0] ?? [] + expect(args).toEqual(expect.arrayContaining([ + 'view', + filePath, + 'screenshot', + '--range', + 'Sheet1!A1:C8' + ])) + expect(args).not.toContain('--sheet') + }) + + it('edits a sibling copy and replaces the original only after validation succeeds', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'kun-office-tools-')) + roots.push(workspace) + const filePath = join(workspace, 'book.xlsx') + const source = Buffer.from('source workbook') + await writeFile(filePath, source) + const commands: string[][] = [] + const run = vi.fn(async (args: readonly string[]) => { + commands.push([...args]) + if (args[0] === 'batch') { + const stagedPath = args[1]! + const batchInput = JSON.parse(await readFile(args[3]!, 'utf8')) + expect(batchInput).toEqual([{ + command: 'set', + path: '/Sheet1/A1', + props: { value: '42' } + }]) + await writeFile(stagedPath, 'edited workbook') + return { stdout: '{"succeeded":1}', stderr: '', exitCode: 0 } + } + if (args[0] === 'validate') { + return { stdout: '{"valid":true}', stderr: '', exitCode: 0 } + } + return { stdout: 'Workbook outline', stderr: '', exitCode: 0 } + }) + const edit = buildOfficeCliLocalTools({ run })[1]! + + const result = await edit.execute({ + path: 'book.xlsx', + expectedSha256: sha256(source), + operations: [{ + type: 'set', + target: '/Sheet1/A1', + props: { value: '42' } + }] + }, context(workspace)) + + expect(result.isError).not.toBe(true) + expect(await readFile(filePath, 'utf8')).toBe('edited workbook') + expect(result.output).toMatchObject({ + path: filePath, + operations: 1, + before_sha256: sha256(source), + after_sha256: sha256('edited workbook'), + preview_invalidated: true + }) + expect(commands.map((args) => args[0])).toEqual(['view', 'batch', 'validate']) + expect(await readdir(workspace)).toEqual(['book.xlsx']) + expect(edit).toMatchObject({ + policy: 'on-request', + toolKind: 'file_change', + externalWritePathArguments: ['path'] + }) + }) + + it('keeps the original byte-identical when validation fails', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'kun-office-tools-')) + roots.push(workspace) + const filePath = join(workspace, 'deck.pptx') + const source = Buffer.from('source deck') + await writeFile(filePath, source) + const run = vi.fn(async (args: readonly string[]) => { + if (args[0] === 'batch') { + await writeFile(args[1]!, 'invalid edited deck') + return { stdout: '', stderr: '', exitCode: 0 } + } + if (args[0] === 'validate') { + return { stdout: '{"valid":false}', stderr: 'schema error', exitCode: 1 } + } + return { stdout: 'Deck outline', stderr: '', exitCode: 0 } + }) + const edit = buildOfficeCliLocalTools({ run })[1]! + + const result = await edit.execute({ + path: 'deck.pptx', + expectedSha256: sha256(source), + operations: [{ type: 'remove', target: '/slide[1]/shape[1]' }] + }, context(workspace)) + + expect(result.isError).toBe(true) + expect(result.output).toMatchObject({ error: expect.stringContaining('schema error') }) + expect(await readFile(filePath)).toEqual(source) + expect(await readdir(workspace)).toEqual(['deck.pptx']) + }) + + it('refuses to overwrite a concurrent external change', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'kun-office-tools-')) + roots.push(workspace) + const filePath = join(workspace, 'report.docx') + const source = Buffer.from('source report') + await writeFile(filePath, source) + const run = vi.fn(async (args: readonly string[]) => { + if (args[0] === 'batch') { + await writeFile(args[1]!, 'edited report') + await writeFile(filePath, 'changed by Word') + return { stdout: '', stderr: '', exitCode: 0 } + } + if (args[0] === 'validate') { + return { stdout: '{"valid":true}', stderr: '', exitCode: 0 } + } + return { stdout: 'Document outline', stderr: '', exitCode: 0 } + }) + const edit = buildOfficeCliLocalTools({ run })[1]! + + const result = await edit.execute({ + path: 'report.docx', + expectedSha256: sha256(source), + operations: [{ + type: 'replace_text', + target: '/body', + find: 'source', + replace: 'edited' + }] + }, context(workspace)) + + expect(result.isError).toBe(true) + expect(await readFile(filePath, 'utf8')).toBe('changed by Word') + expect(await readdir(workspace)).toEqual(['report.docx']) + }) + + it('rejects hard-linked Office targets before creating a staged edit', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'kun-office-tools-')) + roots.push(workspace) + const filePath = join(workspace, 'report.docx') + const aliasPath = join(workspace, 'report-copy.docx') + const source = Buffer.from('source report') + await writeFile(filePath, source) + await link(filePath, aliasPath) + const run = vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })) + const edit = buildOfficeCliLocalTools({ run })[1]! + + const result = await edit.execute({ + path: 'report.docx', + expectedSha256: sha256(source), + operations: [{ type: 'remove', target: '/body/p[1]' }] + }, context(workspace)) + + expect(result).toMatchObject({ + isError: true, + output: { error: expect.stringContaining('exactly one hard link') } + }) + expect(run).not.toHaveBeenCalled() + expect(await readFile(filePath)).toEqual(source) + expect(await readFile(aliasPath)).toEqual(source) + }) +}) diff --git a/kun/src/adapters/tool/office-cli-tool-provider.ts b/kun/src/adapters/tool/office-cli-tool-provider.ts new file mode 100644 index 000000000..205a1853a --- /dev/null +++ b/kun/src/adapters/tool/office-cli-tool-provider.ts @@ -0,0 +1,821 @@ +import { createHash, randomUUID } from 'node:crypto' +import { spawn, type ChildProcess } from 'node:child_process' +import { createReadStream, existsSync } from 'node:fs' +import { + copyFile, + lstat, + mkdir, + readFile, + rename, + rm, + stat, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, extname, join, resolve } from 'node:path' +import type { ToolHostContext } from '../../ports/tool-host.js' +import type { CapabilityToolProvider } from './capability-registry.js' +import { withFileMutationQueue } from './file-mutation-queue.js' +import { LocalToolHost, type LocalTool } from './local-tool-host.js' +import { assertCanWritePath } from './sandbox-policy.js' +import { resolveWorkspacePath, withToolBoundary } from './builtin-tool-utils.js' +import { resolvePathThroughSymlinks, sameFilesystemPath } from './workspace-path.js' + +const OFFICECLI_TIMEOUT_MS = 60_000 +const OFFICECLI_MAX_OUTPUT_BYTES = 2 * 1024 * 1024 +const OFFICECLI_MAX_OPERATIONS = 200 +const OFFICECLI_MAX_PREVIEW_BYTES = 4 * 1024 * 1024 +const OFFICECLI_MAX_CONCURRENCY = 2 +const OFFICECLI_FORMATS = new Set(['.docx', '.xlsx', '.pptx']) + +type OfficeCliRunResult = { + stdout: string + stderr: string + exitCode: number +} + +type OfficeCliRunnerOptions = { + binaryPath: string + profileDir: string + maxConcurrency?: number + logger?: (message: string) => void +} + +type OfficeEditOperation = { + type: 'set' | 'add' | 'remove' | 'move' | 'swap' | 'replace_text' + target?: string + parent?: string + destination?: string + with?: string + elementType?: string + props?: Record + before?: string + after?: string + find?: string + replace?: string + regex?: boolean +} + +type FileIdentity = { + device: bigint + inode: bigint + size: bigint + mtimeNs: bigint + links: bigint + parentDevice: bigint + parentInode: bigint + physicalPath: string +} + +type QueueWaiter = { + resolve: (release: () => void) => void + reject: (error: Error) => void + signal?: AbortSignal + abort?: () => void +} + +export class OfficeCliRunner { + private readonly binaryPath: string + private readonly profileDir: string + private readonly maxConcurrency: number + private readonly logger: (message: string) => void + private active = 0 + private readonly waiters: QueueWaiter[] = [] + + constructor(options: OfficeCliRunnerOptions) { + this.binaryPath = options.binaryPath + this.profileDir = options.profileDir + this.maxConcurrency = Math.max(1, options.maxConcurrency ?? OFFICECLI_MAX_CONCURRENCY) + this.logger = options.logger ?? ((message) => console.error(message)) + } + + async run(args: readonly string[], signal?: AbortSignal): Promise { + const release = await this.acquire(signal) + try { + await mkdir(this.profileDir, { recursive: true, mode: 0o700 }) + return await this.spawn(args, signal) + } finally { + release() + } + } + + async diagnose(): Promise { + try { + const [version, schema] = await Promise.all([ + this.run(['--version']), + this.run(['--output-schema-crc']) + ]) + const versionLabel = version.exitCode === 0 ? version.stdout.trim() : 'unavailable' + const schemaCrc = schema.exitCode === 0 ? schema.stdout.trim() : 'unavailable' + this.logger( + `[officecli] startup version=${boundedLogValue(versionLabel)} ` + + `arch=${process.arch} platform=${process.platform} schema_crc=${boundedLogValue(schemaCrc)}` + ) + } catch (error) { + this.logger(`[officecli] startup diagnostic_failed=${boundedLogValue(errorMessage(error))}`) + } + } + + private acquire(signal?: AbortSignal): Promise<() => void> { + if (signal?.aborted) return Promise.reject(abortError()) + if (this.active < this.maxConcurrency) { + this.active += 1 + return Promise.resolve(() => this.release()) + } + return new Promise<() => void>((resolveWaiter, rejectWaiter) => { + const waiter: QueueWaiter = { + resolve: resolveWaiter, + reject: rejectWaiter, + ...(signal ? { signal } : {}) + } + if (signal) { + waiter.abort = () => { + const index = this.waiters.indexOf(waiter) + if (index >= 0) this.waiters.splice(index, 1) + rejectWaiter(abortError()) + } + signal.addEventListener('abort', waiter.abort, { once: true }) + } + this.waiters.push(waiter) + }) + } + + private release(): void { + const waiter = this.waiters.shift() + if (!waiter) { + this.active = Math.max(0, this.active - 1) + return + } + if (waiter.signal && waiter.abort) { + waiter.signal.removeEventListener('abort', waiter.abort) + } + waiter.resolve(() => this.release()) + } + + private spawn(args: readonly string[], signal?: AbortSignal): Promise { + const category = args[0] || 'unknown' + const startedAt = Date.now() + return new Promise((resolveRun, rejectRun) => { + let child: ChildProcess + try { + child = spawn(this.binaryPath, [...args], { + shell: false, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + env: officeCliEnvironment(this.profileDir) + }) + } catch (error) { + rejectRun(error) + return + } + + let stdout: Buffer = Buffer.alloc(0) + let stderr: Buffer = Buffer.alloc(0) + let settled = false + let timeout: NodeJS.Timeout | undefined + const finish = (callback: () => void, exitCode: number | 'error'): void => { + if (settled) return + settled = true + if (timeout) clearTimeout(timeout) + signal?.removeEventListener('abort', onAbort) + this.logger( + `[officecli] command=${boundedLogValue(category)} duration_ms=${Date.now() - startedAt} ` + + `exit_code=${exitCode}` + ) + callback() + } + const stopForOutputLimit = (): void => { + child.kill() + finish( + () => rejectRun(new Error(`OfficeCLI output exceeds ${OFFICECLI_MAX_OUTPUT_BYTES} bytes.`)), + 'error' + ) + } + const append = ( + current: Buffer, + chunk: Buffer + ): Buffer => { + if (current.length + chunk.length > OFFICECLI_MAX_OUTPUT_BYTES) { + stopForOutputLimit() + return current + } + return Buffer.concat([current, chunk]) + } + const onAbort = (): void => { + child.kill() + finish(() => rejectRun(abortError()), 'error') + } + + child.stdout?.on('data', (chunk: Buffer) => { stdout = append(stdout, chunk) }) + child.stderr?.on('data', (chunk: Buffer) => { stderr = append(stderr, chunk) }) + child.once('error', (error) => finish(() => rejectRun(error), 'error')) + child.once('close', (code) => finish(() => resolveRun({ + stdout: stdout.toString('utf8'), + stderr: stderr.toString('utf8'), + exitCode: code ?? 1 + }), code ?? 1)) + signal?.addEventListener('abort', onAbort, { once: true }) + timeout = setTimeout(() => { + child.kill() + finish( + () => rejectRun(new Error(`OfficeCLI timed out after ${OFFICECLI_TIMEOUT_MS}ms.`)), + 'error' + ) + }, OFFICECLI_TIMEOUT_MS) + }) + } +} + +export function buildOfficeCliToolProviders(options: { + binaryPath?: string + profileDir: string + runner?: OfficeCliRunner +}): CapabilityToolProvider[] { + const binaryPath = options.binaryPath?.trim() + if (!options.runner && (!binaryPath || !existsSync(binaryPath))) return [] + const runner = options.runner ?? new OfficeCliRunner({ + binaryPath: binaryPath!, + profileDir: options.profileDir + }) + if (!options.runner) void runner.diagnose() + return [{ + id: 'officecli', + kind: 'built-in', + enabled: true, + available: true, + tools: buildOfficeCliLocalTools(runner) + }] +} + +export function buildOfficeCliLocalTools(runner: Pick): LocalTool[] { + return [ + createOfficeInspectTool(runner), + createOfficeEditTool(runner), + createOfficePreviewTool(runner) + ] +} + +function createOfficeInspectTool(runner: Pick): LocalTool { + return LocalToolHost.defineTool({ + name: 'office_inspect', + description: + 'Inspect an existing DOCX, XLSX, or PPTX file. Use this before office_edit. ' + + 'Returns the resolved path, document format, current SHA-256, and bounded structured content.', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string' }, + action: { + type: 'string', + enum: ['summary', 'text', 'outline', 'query', 'issues', 'validate'] + }, + target: { type: 'string' }, + maxLines: { type: 'integer', minimum: 1, maximum: 4000 } + }, + required: ['path', 'action'], + additionalProperties: false + }, + policy: 'auto', + toolKind: 'tool_call', + sideEffect: 'read-only', + execute: async (args, context) => withToolBoundary(async () => { + const rawPath = stringArgument(args.path) + const action = stringArgument(args.action) + const target = stringArgument(args.target) + if (!rawPath || !isInspectAction(action)) { + return { output: { error: 'path and a supported action are required' }, isError: true } + } + const resolvedPath = await resolveWorkspacePath(rawPath, context) + const format = officeFormat(resolvedPath.absolutePath) + const sourceSha256 = await sha256File(resolvedPath.absolutePath, context.abortSignal) + const command = inspectCommand( + resolvedPath.absolutePath, + action, + target, + integerArgument(args.maxLines, 1, 4000) ?? 1000 + ) + const result = await runner.run(command, context.abortSignal) + assertOfficeCliSuccess(result, `Office ${action} failed`) + return { + output: { + path: resolvedPath.absolutePath, + relative_path: resolvedPath.relativePath, + format, + source_sha256: sourceSha256, + action, + result: parseOfficeCliOutput(result.stdout) + } + } + }) + }) +} + +function createOfficePreviewTool(runner: Pick): LocalTool { + return LocalToolHost.defineTool({ + name: 'office_preview', + description: + 'Generate a bounded self-contained HTML preview for an existing DOCX, XLSX, or PPTX. ' + + 'Optionally scope it to a page, slide, sheet, or cell range when OfficeCLI supports that format.', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string' }, + page: { type: 'integer', minimum: 1 }, + sheet: { type: 'string' }, + range: { type: 'string' } + }, + required: ['path'], + additionalProperties: false + }, + policy: 'auto', + toolKind: 'tool_call', + sideEffect: 'read-only', + execute: async (args, context) => withToolBoundary(async () => { + const rawPath = stringArgument(args.path) + if (!rawPath) { + return { output: { error: 'path is required' }, isError: true } + } + const resolvedPath = await resolveWorkspacePath(rawPath, context) + const format = officeFormat(resolvedPath.absolutePath) + const command = ['view', resolvedPath.absolutePath, 'html'] + const page = integerArgument(args.page, 1, 100_000) + const sheet = stringArgument(args.sheet) + const range = stringArgument(args.range) + if (page || sheet || range) { + const previewRoot = join(context.runtimeDataDir || tmpdir(), 'officecli-previews') + const outputPath = join(previewRoot, `${randomUUID()}.png`) + await mkdir(previewRoot, { recursive: true, mode: 0o700 }) + const screenshotCommand = [ + 'view', + resolvedPath.absolutePath, + 'screenshot', + '--out', + outputPath + ] + if (page) screenshotCommand.push('--page', String(page)) + const scopedRange = sheet + ? `${sheet}!${range || 'A1:Z200'}` + : range + if (scopedRange) screenshotCommand.push('--range', scopedRange) + try { + const result = await runner.run(screenshotCommand, context.abortSignal) + assertOfficeCliSuccess(result, 'Office visual preview failed') + const image = await readFile(outputPath) + if (image.byteLength <= 0 || image.byteLength > OFFICECLI_MAX_PREVIEW_BYTES) { + throw new Error( + `Office visual preview exceeds ${OFFICECLI_MAX_PREVIEW_BYTES} bytes.` + ) + } + return { + output: { + kind: 'image', + path: resolvedPath.absolutePath, + relative_path: resolvedPath.relativePath, + format, + source_sha256: await sha256File( + resolvedPath.absolutePath, + context.abortSignal + ), + mime_type: 'image/png', + data_base64: image.toString('base64') + } + } + } finally { + await rm(outputPath, { force: true }).catch(() => undefined) + } + } + + const result = await runner.run(command, context.abortSignal) + assertOfficeCliSuccess(result, 'Office HTML preview failed') + return { + output: { + path: resolvedPath.absolutePath, + relative_path: resolvedPath.relativePath, + format, + source_sha256: await sha256File(resolvedPath.absolutePath, context.abortSignal), + html: result.stdout + } + } + }) + }) +} + +function createOfficeEditTool(runner: Pick): LocalTool { + return LocalToolHost.defineTool({ + name: 'office_edit', + description: + 'Atomically edit an existing DOCX, XLSX, or PPTX using controlled structured operations. ' + + 'Call office_inspect first and pass its exact source_sha256 as expectedSha256. ' + + 'The original file is replaced only after the batch and OpenXML validation both succeed.', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string' }, + expectedSha256: { type: 'string', pattern: '^[a-f0-9]{64}$' }, + operations: { + type: 'array', + minItems: 1, + maxItems: OFFICECLI_MAX_OPERATIONS, + items: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['set', 'add', 'remove', 'move', 'swap', 'replace_text'] + }, + target: { type: 'string' }, + parent: { type: 'string' }, + destination: { type: 'string' }, + with: { type: 'string' }, + elementType: { type: 'string' }, + props: { + type: 'object', + additionalProperties: { + oneOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'null' } + ] + } + }, + before: { type: 'string' }, + after: { type: 'string' }, + find: { type: 'string' }, + replace: { type: 'string' }, + regex: { type: 'boolean' } + }, + required: ['type'], + additionalProperties: false + } + } + }, + required: ['path', 'expectedSha256', 'operations'], + additionalProperties: false + }, + policy: 'on-request', + toolKind: 'file_change', + externalWritePathArguments: ['path'], + execute: async (args, context) => withToolBoundary(async () => { + const rawPath = stringArgument(args.path) + const expectedSha256 = stringArgument(args.expectedSha256).toLowerCase() + const operations = parseOfficeEditOperations(args.operations) + if (!rawPath || !/^[a-f0-9]{64}$/.test(expectedSha256) || operations.length === 0) { + return { + output: { error: 'path, expectedSha256, and at least one valid operation are required' }, + isError: true + } + } + const resolvedPath = await resolveWorkspacePath(rawPath, context) + officeFormat(resolvedPath.absolutePath) + assertCanWritePath(resolvedPath.absolutePath, context) + return withFileMutationQueue(resolvedPath.absolutePath, async () => + executeAtomicOfficeEdit({ + runner, + context, + absolutePath: resolvedPath.absolutePath, + relativePath: resolvedPath.relativePath, + expectedSha256, + operations + })) + }) + }) +} + +async function executeAtomicOfficeEdit(input: { + runner: Pick + context: ToolHostContext + absolutePath: string + relativePath: string + expectedSha256: string + operations: OfficeEditOperation[] +}): Promise<{ output: unknown }> { + const identity = await captureFileIdentity(input.absolutePath) + const beforeSha256 = await sha256File(input.absolutePath, input.context.abortSignal) + if (beforeSha256 !== input.expectedSha256) { + throw new Error( + `Office document changed since inspection: expected ${input.expectedSha256}, found ${beforeSha256}.` + ) + } + + const inspection = await input.runner.run( + ['view', input.absolutePath, 'outline'], + input.context.abortSignal + ) + assertOfficeCliSuccess(inspection, 'Office pre-edit inspection failed') + + const extension = extname(input.absolutePath) + const stem = basename(input.absolutePath, extension) + const temporaryPath = join( + dirname(input.absolutePath), + `.${stem}.kun-office-${randomUUID()}${extension}` + ) + const commandPath = join( + dirname(input.absolutePath), + `.${stem}.kun-office-${randomUUID()}.json` + ) + try { + await copyFile(input.absolutePath, temporaryPath) + const commands = input.operations.map(toOfficeCliBatchItem) + await writeFile(commandPath, JSON.stringify(commands), { encoding: 'utf8', mode: 0o600 }) + + const batch = await input.runner.run( + ['batch', temporaryPath, '--input', commandPath, '--json'], + input.context.abortSignal + ) + assertOfficeCliSuccess(batch, 'Office edit batch failed') + + const validation = await input.runner.run( + ['validate', temporaryPath, '--json'], + input.context.abortSignal + ) + assertOfficeCliSuccess(validation, 'Edited Office document failed validation') + + const afterSha256 = await sha256File(temporaryPath, input.context.abortSignal) + await assertFileIdentityUnchanged(input.absolutePath, identity) + const currentSha256 = await sha256File(input.absolutePath, input.context.abortSignal) + if (currentSha256 !== beforeSha256) { + throw new Error('Office document contents changed while the edit was being prepared.') + } + + await rename(temporaryPath, input.absolutePath) + return { + output: { + path: input.absolutePath, + relative_path: input.relativePath, + operations: input.operations.length, + before_sha256: beforeSha256, + after_sha256: afterSha256, + validation: parseOfficeCliOutput(validation.stdout), + preview_invalidated: true + } + } + } finally { + await Promise.all([ + rm(temporaryPath, { force: true }).catch(() => undefined), + rm(commandPath, { force: true }).catch(() => undefined) + ]) + } +} + +function toOfficeCliBatchItem(operation: OfficeEditOperation): Record { + if (operation.type === 'set') { + if (!operation.target || !operation.props || Object.keys(operation.props).length === 0) { + throw new Error('set operations require target and props') + } + return { command: 'set', path: operation.target, props: operation.props } + } + if (operation.type === 'add') { + const parent = operation.parent || operation.target + if (!parent || !operation.elementType) { + throw new Error('add operations require parent (or target) and elementType') + } + return { + command: 'add', + parent, + type: operation.elementType, + ...(operation.props ? { props: operation.props } : {}), + ...(operation.before ? { before: operation.before } : {}), + ...(operation.after ? { after: operation.after } : {}) + } + } + if (operation.type === 'remove') { + if (!operation.target) throw new Error('remove operations require target') + return { command: 'remove', path: operation.target } + } + if (operation.type === 'move') { + if (!operation.target || !operation.destination) { + throw new Error('move operations require target and destination') + } + return { + command: 'move', + path: operation.target, + to: operation.destination, + ...(operation.before ? { before: operation.before } : {}), + ...(operation.after ? { after: operation.after } : {}) + } + } + if (operation.type === 'swap') { + if (!operation.target || !operation.with) throw new Error('swap operations require target and with') + return { command: 'swap', path: operation.target, path2: operation.with } + } + if (!operation.target || operation.find == null || operation.replace == null) { + throw new Error('replace_text operations require target, find, and replace') + } + return { + command: 'set', + path: operation.target, + props: { + find: operation.find, + replace: operation.replace, + ...(operation.regex != null ? { regex: operation.regex } : {}) + } + } +} + +function parseOfficeEditOperations(value: unknown): OfficeEditOperation[] { + if (!Array.isArray(value) || value.length > OFFICECLI_MAX_OPERATIONS) return [] + return value.flatMap((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return [] + const record = item as Record + const type = stringArgument(record.type) + if (!isOfficeEditOperationType(type)) return [] + const props = scalarRecord(record.props) + return [{ + type, + target: optionalString(record.target), + parent: optionalString(record.parent), + destination: optionalString(record.destination), + with: optionalString(record.with), + elementType: optionalString(record.elementType), + ...(props ? { props } : {}), + before: optionalString(record.before), + after: optionalString(record.after), + find: optionalRawString(record.find), + replace: optionalRawString(record.replace), + ...(typeof record.regex === 'boolean' ? { regex: record.regex } : {}) + }] + }) +} + +function inspectCommand( + filePath: string, + action: 'summary' | 'text' | 'outline' | 'query' | 'issues' | 'validate', + target: string, + maxLines: number +): string[] { + if (action === 'validate') return ['validate', filePath, '--json'] + if (action === 'query') { + if (!target) throw new Error('query inspection requires target') + return ['query', filePath, target, '--json'] + } + if (action === 'issues') return ['view', filePath, 'issues', '--json'] + if (action === 'summary') return ['view', filePath, 'stats', '--json'] + if (action === 'text') return ['view', filePath, 'text', '--max-lines', String(maxLines)] + return ['view', filePath, 'outline'] +} + +function officeFormat(filePath: string): 'docx' | 'xlsx' | 'pptx' { + const extension = extname(filePath).toLowerCase() + if (!OFFICECLI_FORMATS.has(extension)) { + throw new Error('Office tools support existing .docx, .xlsx, and .pptx files only.') + } + return extension.slice(1) as 'docx' | 'xlsx' | 'pptx' +} + +async function captureFileIdentity(filePath: string): Promise { + const lexical = resolve(filePath) + const linkInfo = await lstat(lexical, { bigint: true }) + if (linkInfo.isSymbolicLink()) throw new Error('Office edits do not follow symbolic links.') + if (!linkInfo.isFile()) throw new Error('Office edit target is not a regular file.') + if (linkInfo.nlink !== 1n) throw new Error('Office edit target must have exactly one hard link.') + if (linkInfo.ino === 0n) throw new Error('Office edit target has no stable inode identity.') + const physicalPath = await resolvePathThroughSymlinks(lexical) + const parent = await stat(dirname(lexical), { bigint: true }) + if (!parent.isDirectory() || parent.ino === 0n) { + throw new Error('Office edit target parent has no stable directory identity.') + } + return { + device: linkInfo.dev, + inode: linkInfo.ino, + size: linkInfo.size, + mtimeNs: linkInfo.mtimeNs, + links: linkInfo.nlink, + parentDevice: parent.dev, + parentInode: parent.ino, + physicalPath + } +} + +async function assertFileIdentityUnchanged(filePath: string, expected: FileIdentity): Promise { + const current = await captureFileIdentity(filePath) + if ( + current.device !== expected.device || + current.inode !== expected.inode || + current.size !== expected.size || + current.mtimeNs !== expected.mtimeNs || + current.links !== expected.links || + current.parentDevice !== expected.parentDevice || + current.parentInode !== expected.parentInode || + !sameFilesystemPath(current.physicalPath, expected.physicalPath) + ) { + throw new Error('Office document identity or parent directory changed while editing.') + } +} + +function sha256File(filePath: string, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(abortError()) + return new Promise((resolveHash, rejectHash) => { + const hash = createHash('sha256') + const stream = createReadStream(filePath) + const onAbort = (): void => { + stream.destroy(abortError()) + } + signal?.addEventListener('abort', onAbort, { once: true }) + stream.on('data', (chunk) => { + hash.update(chunk) + }) + stream.once('error', (error) => { + signal?.removeEventListener('abort', onAbort) + rejectHash(error) + }) + stream.once('end', () => { + signal?.removeEventListener('abort', onAbort) + resolveHash(hash.digest('hex')) + }) + }) +} + +function officeCliEnvironment(profileDir: string): NodeJS.ProcessEnv { + return { + ...process.env, + OFFICECLI_SKIP_UPDATE: '1', + OFFICECLI_NO_AUTO_INSTALL: '1', + OFFICECLI_NO_AUTO_RESIDENT: '1', + OFFICECLI_RESIDENT_FLUSH: 'each', + HOME: profileDir, + USERPROFILE: profileDir, + APPDATA: profileDir, + LOCALAPPDATA: profileDir, + XDG_CONFIG_HOME: profileDir + } +} + +function assertOfficeCliSuccess(result: OfficeCliRunResult, fallback: string): void { + if (result.exitCode === 0) return + const detail = result.stderr.trim() || result.stdout.trim() + throw new Error(detail ? `${fallback}: ${detail}` : fallback) +} + +function parseOfficeCliOutput(raw: string): unknown { + const trimmed = raw.trim() + if (!trimmed) return '' + try { + return JSON.parse(trimmed) + } catch { + return trimmed + } +} + +function scalarRecord(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined + const output: Record = {} + for (const [key, item] of Object.entries(value as Record)) { + if ( + typeof item !== 'string' && + typeof item !== 'number' && + typeof item !== 'boolean' && + item !== null + ) { + return undefined + } + output[key] = item + } + return output +} + +function stringArgument(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function optionalString(value: unknown): string | undefined { + const text = stringArgument(value) + return text || undefined +} + +function optionalRawString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +function integerArgument(value: unknown, minimum: number, maximum: number): number | undefined { + return typeof value === 'number' && + Number.isSafeInteger(value) && + value >= minimum && + value <= maximum + ? value + : undefined +} + +function isInspectAction( + value: string +): value is 'summary' | 'text' | 'outline' | 'query' | 'issues' | 'validate' { + return ['summary', 'text', 'outline', 'query', 'issues', 'validate'].includes(value) +} + +function isOfficeEditOperationType(value: string): value is OfficeEditOperation['type'] { + return ['set', 'add', 'remove', 'move', 'swap', 'replace_text'].includes(value) +} + +function abortError(): Error { + const error = new Error('OfficeCLI operation aborted.') + error.name = 'AbortError' + return error +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function boundedLogValue(value: string): string { + return value.replace(/\s+/g, '_').slice(0, 160) || 'unknown' +} diff --git a/kun/src/attachments/attachment-store.ts b/kun/src/attachments/attachment-store.ts index ad1fc1375..08d5ee4fb 100644 --- a/kun/src/attachments/attachment-store.ts +++ b/kun/src/attachments/attachment-store.ts @@ -2,7 +2,12 @@ import { createHash } from 'node:crypto' import { chmod, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import type { AttachmentsCapabilityConfig } from '../contracts/capabilities.js' -import type { AttachmentDiagnostics, AttachmentMetadata, AttachmentTextFallback } from '../contracts/attachments.js' +import type { + AttachmentDiagnostics, + AttachmentMetadata, + AttachmentTextFallback, + AttachmentVisualPreview +} from '../contracts/attachments.js' import { AttachmentMetadata as AttachmentMetadataSchema } from '../contracts/attachments.js' const ATTACHMENT_ID_PATTERN = /^att_[0-9a-f]{24}$/ @@ -17,13 +22,18 @@ export interface AttachmentStore { data: Buffer mimeType?: string documentText?: string + documentFormat?: AttachmentMetadata['documentFormat'] + sourceSha256?: string pageCount?: number localFilePath?: string textFallback?: AttachmentTextFallback + visualPreview?: AttachmentVisualPreview threadId?: string workspace?: string }): Promise get(id: string): Promise + bindScope(id: string, scope: { threadId?: string; workspace?: string }): Promise + bindScopes(ids: readonly string[], scope: { threadId?: string; workspace?: string }): Promise delete?(id: string): Promise replaceMetadata?(metadata: AttachmentMetadata): Promise resolveContent(id: string, scope: { threadId?: string; workspace?: string }): Promise @@ -48,9 +58,12 @@ export class FileAttachmentStore implements AttachmentStore { data: Buffer mimeType?: string documentText?: string + documentFormat?: AttachmentMetadata['documentFormat'] + sourceSha256?: string pageCount?: number localFilePath?: string textFallback?: AttachmentTextFallback + visualPreview?: AttachmentVisualPreview threadId?: string workspace?: string }): Promise { @@ -58,7 +71,11 @@ export class FileAttachmentStore implements AttachmentStore { const image = detectImage(input.data) const descriptor = image ? this.describeImage(image, input) : this.describeDocument(input) if (input.textFallback) validateTextFallback(input.textFallback, this.options.config) + if (input.visualPreview) validateTextFallback(input.visualPreview, this.options.config) const hash = createHash('sha256').update(input.data).digest('hex') + if (input.sourceSha256 && input.sourceSha256 !== hash) { + throw new Error('declared source SHA-256 does not match attachment content') + } const id = `att_${hash.slice(0, 24)}` const contentPath = this.contentPath(id) const metadataPath = this.metadataPath(id) @@ -71,7 +88,10 @@ export class FileAttachmentStore implements AttachmentStore { mimeType: descriptor.mimeType, ...(input.localFilePath ? { localFilePath: input.localFilePath } : {}), ...(input.textFallback ? { textFallback: input.textFallback } : {}), + ...(input.visualPreview ? { visualPreview: input.visualPreview } : {}), ...(descriptor.documentText !== undefined ? { documentText: descriptor.documentText } : {}), + ...(input.documentFormat ? { documentFormat: input.documentFormat } : {}), + sourceSha256: hash, ...(descriptor.pageCount ? { pageCount: descriptor.pageCount } : {}), ...(descriptor.truncated !== undefined ? { truncated: descriptor.truncated } : {}), updatedAt: now @@ -90,10 +110,13 @@ export class FileAttachmentStore implements AttachmentStore { ...(descriptor.width ? { width: descriptor.width } : {}), ...(descriptor.height ? { height: descriptor.height } : {}), ...(descriptor.documentText !== undefined ? { documentText: descriptor.documentText } : {}), + ...(input.documentFormat ? { documentFormat: input.documentFormat } : {}), + sourceSha256: hash, ...(descriptor.pageCount ? { pageCount: descriptor.pageCount } : {}), ...(descriptor.truncated !== undefined ? { truncated: descriptor.truncated } : {}), ...(input.localFilePath ? { localFilePath: input.localFilePath } : {}), ...(input.textFallback ? { textFallback: input.textFallback } : {}), + ...(input.visualPreview ? { visualPreview: input.visualPreview } : {}), threadIds: [], workspaces: [], createdAt: now, @@ -156,6 +179,66 @@ export class FileAttachmentStore implements AttachmentStore { } } + async bindScope(id: string, scope: { threadId?: string; workspace?: string }): Promise { + const [metadata] = await this.bindScopes([id], scope) + return metadata + } + + async bindScopes( + ids: readonly string[], + scope: { threadId?: string; workspace?: string } + ): Promise { + const attachmentIds = [...new Set(ids)] + if (attachmentIds.length === 0) return [] + return withAttachmentStoreLock(this.options.rootDir, async () => { + await this.ensureRoot() + const records = await Promise.all(attachmentIds.map(async (id) => { + if (!ATTACHMENT_ID_PATTERN.test(id)) throw new Error(`invalid attachment id: ${id}`) + const metadataText = await readFile(this.metadataPath(id), 'utf8') + .catch(() => null) + if (metadataText === null) throw new Error(`attachment not found: ${id}`) + let metadata: AttachmentMetadata + try { + metadata = AttachmentMetadataSchema.parse(JSON.parse(metadataText)) + } catch { + throw new Error(`attachment not found: ${id}`) + } + if (!isAuthorized(metadata, scope)) { + throw new Error(`attachment is not authorized for this turn: ${id}`) + } + await readFile(this.contentPath(id)) + return { id, metadata, metadataText } + })) + const now = this.options.nowIso?.() ?? new Date().toISOString() + const nextRecords = records.map(({ metadata }) => + AttachmentMetadataSchema.parse(mergeScope({ + ...metadata, + updatedAt: now + }, scope)) + ) + const written: number[] = [] + try { + for (let index = 0; index < records.length; index += 1) { + await writeFile( + this.metadataPath(records[index].id), + JSON.stringify(nextRecords[index], null, 2), + { encoding: 'utf8', mode: 0o600 } + ) + written.push(index) + } + } catch (error) { + await Promise.allSettled(written.map((index) => + writeFile(this.metadataPath(records[index].id), records[index].metadataText, { + encoding: 'utf8', + mode: 0o600 + }) + )) + throw error + } + return nextRecords + }) + } + async delete(id: string): Promise { if (!ATTACHMENT_ID_PATTERN.test(id)) throw new Error(`invalid attachment id: ${id}`) await Promise.all([ @@ -227,6 +310,25 @@ export class FileAttachmentStore implements AttachmentStore { } } +const attachmentStoreLocks = new Map>() + +async function withAttachmentStoreLock(rootDir: string, operation: () => Promise): Promise { + const previous = attachmentStoreLocks.get(rootDir) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + const tail = previous.then(() => current) + attachmentStoreLocks.set(rootDir, tail) + await previous + try { + return await operation() + } finally { + release() + if (attachmentStoreLocks.get(rootDir) === tail) attachmentStoreLocks.delete(rootDir) + } +} + function mergeScope(metadata: T, input: { threadId?: string; workspace?: string }): T { return { ...metadata, @@ -270,14 +372,51 @@ type AttachmentDescriptor = { } function resolveDocumentMimeType(input: { data: Buffer; mimeType?: string }): string | undefined { - if (input.data.length >= 5 && input.data.subarray(0, 5).toString('ascii') === '%PDF-') { + const declared = input.mimeType?.trim().toLowerCase() + const isPdf = input.data.length >= 5 && + input.data.subarray(0, 5).toString('ascii') === '%PDF-' + if (isPdf) { + if (declared && declared !== 'application/pdf') { + throw new Error(`declared MIME type does not match PDF content: ${declared}`) + } return 'application/pdf' } - return input.mimeType?.trim().toLowerCase() || undefined + if (declared === 'application/pdf') return undefined + + if (declared && isOoxmlMimeType(declared)) { + const zipSignature = input.data.length >= 4 + ? input.data.subarray(0, 4).toString('hex') + : '' + if (!['504b0304', '504b0506', '504b0708'].includes(zipSignature)) return undefined + } + return declared || undefined +} + +function isOoxmlMimeType(mimeType: string): boolean { + return mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || + mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || + mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' } function decodeTextDocument(mimeType: string, data: Buffer): string | undefined { - if (!mimeType.startsWith('text/') && mimeType !== 'application/json') return undefined + if ( + !mimeType.startsWith('text/') && + mimeType !== 'application/json' && + mimeType !== 'application/xml' + ) return undefined + if (data.length >= 2 && data[0] === 0xff && data[1] === 0xfe) { + const body = data.subarray(2, data.length - ((data.length - 2) % 2)) + return body.toString('utf16le') + } + if (data.length >= 2 && data[0] === 0xfe && data[1] === 0xff) { + const body = Buffer.from(data.subarray(2, data.length - ((data.length - 2) % 2))) + for (let index = 0; index + 1 < body.length; index += 2) { + const first = body[index] + body[index] = body[index + 1] + body[index + 1] = first + } + return body.toString('utf16le') + } return data.toString('utf8').replace(/^\uFEFF/, '') } diff --git a/kun/src/config/kun-config.ts b/kun/src/config/kun-config.ts index d2777c873..ae66e0710 100644 --- a/kun/src/config/kun-config.ts +++ b/kun/src/config/kun-config.ts @@ -175,7 +175,7 @@ export const RuntimeTuningConfigSchema = z .object({ maxSteps: PositiveInt.max(1_000).optional(), maxWallTimeMs: PositiveInt.max(86_400_000).optional(), - maxToolCallsPerStep: PositiveInt.max(256).optional(), + maxToolCallsPerStep: PositiveInt.max(10_000).optional(), /** Global in-process admission cap for concurrently active turns. */ maxConcurrentTurns: PositiveInt.max(256).optional() }) @@ -291,9 +291,17 @@ export const ServeProviderConfigSchema = z * existing Claude Code login). `antigravity-cli` delegates whole turns to * Google's official Antigravity CLI and uses its existing subscription login. * `cursor-sdk` delegates whole turns to the official Cursor SDK and requires - * the provider's Cursor API key. + * the provider's Cursor API key. `gemini-cli-api` reuses the official + * Gemini CLI OAuth login and calls Code Assist directly through Kun's + * model loop. */ - kind: z.enum(['http', 'agent-sdk', 'antigravity-cli', 'cursor-sdk']).default('http').optional(), + kind: z.enum([ + 'http', + 'agent-sdk', + 'antigravity-cli', + 'gemini-cli-api', + 'cursor-sdk' + ]).default('http').optional(), apiKey: z.string().default(''), /** Opaque binding key resolved through the protected account store. */ credentialSourceId: z.string().min(1).max(256).optional(), @@ -304,6 +312,7 @@ export const ServeProviderConfigSchema = z .optional(), retry: ModelRequestRetryConfigSchema.optional(), modelProxyUrl: z.string().optional(), + modelProfiles: z.record(z.string().min(1), ModelContextProfileConfigSchema).optional(), headers: z.record(z.string(), z.string()).optional() }) .strict() diff --git a/kun/src/contracts/attachments.ts b/kun/src/contracts/attachments.ts index 5999b44b0..16c44f061 100644 --- a/kun/src/contracts/attachments.ts +++ b/kun/src/contracts/attachments.ts @@ -13,6 +13,8 @@ export const AttachmentTextFallback = z.object({ wasCompressed: z.boolean().optional() }).strict() export type AttachmentTextFallback = z.infer +export const AttachmentVisualPreview = AttachmentTextFallback +export type AttachmentVisualPreview = z.infer export const AttachmentMetadata = z.object({ id: z.string().min(1), @@ -24,10 +26,13 @@ export const AttachmentMetadata = z.object({ width: z.number().int().positive().optional(), height: z.number().int().positive().optional(), documentText: z.string().optional(), + documentFormat: z.enum(['pdf', 'docx', 'xlsx', 'pptx', 'text', 'csv', 'json', 'xml']).optional(), + sourceSha256: z.string().regex(/^[a-f0-9]{64}$/).optional(), pageCount: z.number().int().positive().optional(), truncated: z.boolean().optional(), localFilePath: z.string().min(1).optional(), textFallback: AttachmentTextFallback.optional(), + visualPreview: AttachmentVisualPreview.optional(), threadIds: z.array(z.string().min(1)).default([]), workspaces: z.array(z.string().min(1)).default([]), createdAt: z.string(), @@ -40,9 +45,12 @@ export const AttachmentUploadRequest = z.object({ mimeType: z.string().min(1).optional(), dataBase64: z.string().min(1), documentText: z.string().optional(), + documentFormat: z.enum(['pdf', 'docx', 'xlsx', 'pptx', 'text', 'csv', 'json', 'xml']).optional(), + sourceSha256: z.string().regex(/^[a-f0-9]{64}$/).optional(), pageCount: z.number().int().positive().optional(), localFilePath: z.string().min(1).optional(), textFallback: AttachmentTextFallback.optional(), + visualPreview: AttachmentVisualPreview.optional(), threadId: z.string().min(1).optional(), workspace: z.string().min(1).optional() }).strict() diff --git a/kun/src/contracts/capabilities.ts b/kun/src/contracts/capabilities.ts index db067c763..74b7d10fa 100644 --- a/kun/src/contracts/capabilities.ts +++ b/kun/src/contracts/capabilities.ts @@ -143,6 +143,8 @@ export const McpServerConfig = z oauth: McpOAuthConfig.optional(), trustScope: McpTrustScope.default('workspace'), trustedWorkspaceRoots: z.array(z.string().min(1)).default([]), + /** MCP tool names explicitly trusted by the host as read-only in Plan mode. */ + planModeReadOnlyTools: z.array(z.string().min(1)).default([]), timeoutMs: z.number().int().positive().default(30_000) }) .strict() @@ -187,7 +189,10 @@ export const McpServerConfig = z }) } }) -export type McpServerConfig = z.infer +type ParsedMcpServerConfig = z.infer +export type McpServerConfig = Omit & { + planModeReadOnlyTools?: string[] +} export const McpCapabilityConfig = CapabilityToggleConfig.extend({ servers: z.record(z.string().min(1), McpServerConfig).default({}), @@ -377,7 +382,13 @@ export const DEFAULT_ATTACHMENT_DOCUMENT_MIME_TYPES = [ 'text/plain', 'text/markdown', 'text/csv', - 'application/json' + 'text/tab-separated-values', + 'application/json', + 'application/xml', + 'text/xml', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ] export const DEFAULT_ATTACHMENT_MAX_DOCUMENT_BYTES = 10 * 1024 * 1024 export const DEFAULT_ATTACHMENT_MAX_DOCUMENT_TEXT_CHARS = 200_000 diff --git a/kun/src/contracts/events.delegated.test.ts b/kun/src/contracts/events.delegated.test.ts new file mode 100644 index 000000000..2223d9349 --- /dev/null +++ b/kun/src/contracts/events.delegated.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'vitest' +import { RuntimeEvent } from './events.js' + +const capabilities = { + nativeResume: true, + structuredStreaming: true, + kunTools: false, + externalApproval: false, + liveSteering: false, + nativeContextTelemetry: false, + fork: false +} + +describe('delegated runtime event contract', () => { + test('keeps bounded capability metadata and strips native session identifiers', () => { + const parsed = RuntimeEvent.parse({ + kind: 'delegated_runtime', + seq: 1, + timestamp: '2026-07-25T00:00:00.000Z', + threadId: 'thread_1', + turnId: 'turn_1', + providerKind: 'cursor-sdk', + providerId: 'cursor-subscription', + phase: 'rebased', + reason: 'history_changed', + capabilities, + nativeSessionId: 'must-not-be-projected' + }) + expect(parsed).toMatchObject({ + kind: 'delegated_runtime', + phase: 'rebased', + capabilities + }) + expect(parsed).not.toHaveProperty('nativeSessionId') + }) + + test('rejects incomplete capability snapshots', () => { + expect(() => RuntimeEvent.parse({ + kind: 'delegated_runtime', + seq: 1, + timestamp: '2026-07-25T00:00:00.000Z', + threadId: 'thread_1', + providerKind: 'agent-sdk', + providerId: 'claude-subscription', + phase: 'resumed', + capabilities: { nativeResume: true } + })).toThrow() + }) +}) diff --git a/kun/src/contracts/events.ts b/kun/src/contracts/events.ts index 43b1a5029..17186dcd9 100644 --- a/kun/src/contracts/events.ts +++ b/kun/src/contracts/events.ts @@ -49,6 +49,8 @@ export const RuntimeEventKind = z.enum([ 'bash_session_updated', 'bash_session_completed', 'pipeline_stage', + 'delegated_runtime', + 'context_snapshot', 'usage', 'error', 'heartbeat' @@ -256,6 +258,58 @@ export const BashSessionEvent = RuntimeEventBase.extend({ }) export type BashSessionEvent = z.infer +export const RequestContextTokenBreakdownSchema = z.object({ + tools: z.number().int().nonnegative(), + system: z.number().int().nonnegative(), + skills: z.number().int().nonnegative(), + messages: z.number().int().nonnegative(), + other: z.number().int().nonnegative() +}) +export type RequestContextTokenBreakdown = z.infer + +export const ContextSnapshotEvent = RuntimeEventBase.extend({ + kind: z.literal('context_snapshot'), + model: z.string().min(1), + providerId: z.string().min(1).optional(), + stepIndex: z.number().int().nonnegative(), + contextWindowTokens: z.number().int().positive(), + softThresholdTokens: z.number().int().positive(), + hardThresholdTokens: z.number().int().positive(), + estimatedInputTokens: z.number().int().nonnegative(), + breakdown: RequestContextTokenBreakdownSchema, + toolCount: z.number().int().nonnegative(), + activeSkillIds: z.array(z.string().min(1)), + contextManagement: z.enum(['kun-managed', 'sdk-managed']).optional(), + nativeHistory: z.enum(['known', 'unknown', 'none']).optional() +}) +export type ContextSnapshotEvent = z.infer + +export const DelegatedRuntimeCapabilitiesSchema = z.object({ + nativeResume: z.boolean(), + structuredStreaming: z.boolean(), + kunTools: z.boolean(), + externalApproval: z.boolean(), + liveSteering: z.boolean(), + nativeContextTelemetry: z.boolean(), + fork: z.boolean() +}) + +export const DelegatedRuntimeEvent = RuntimeEventBase.extend({ + kind: z.literal('delegated_runtime'), + providerKind: z.enum(['agent-sdk', 'cursor-sdk', 'antigravity-cli']), + providerId: z.string().min(1), + phase: z.enum(['portable', 'resumed', 'rebased']), + reason: z.enum([ + 'new', + 'route_changed', + 'capabilities_changed', + 'history_changed', + 'native_state_unavailable' + ]).optional(), + capabilities: DelegatedRuntimeCapabilitiesSchema +}) +export type DelegatedRuntimeEvent = z.infer + export const UsageEvent = RuntimeEventBase.extend({ kind: z.literal('usage'), model: z.string().optional(), @@ -301,6 +355,8 @@ export const RuntimeEvent = z.discriminatedUnion('kind', [ TodoEvent, BashSessionEvent, PipelineStageEvent, + DelegatedRuntimeEvent, + ContextSnapshotEvent, UsageEvent, ErrorEvent, HeartbeatEvent diff --git a/kun/src/contracts/items.ts b/kun/src/contracts/items.ts index 730af198b..382f09d85 100644 --- a/kun/src/contracts/items.ts +++ b/kun/src/contracts/items.ts @@ -99,9 +99,20 @@ export const ToolCallTurnItem = TurnItemBase.extend({ callId: z.string().min(1), toolKind: z.enum(['tool_call', 'command_execution', 'file_change']), arguments: z.record(z.string(), z.unknown()), + /** + * Bounded provider-owned continuation data required to replay a tool call. + * It is persisted with canonical history but never sent to tools or + * providers other than the owning adapter. + */ + providerMetadata: z.object({ + gemini: z.object({ + thoughtSignature: z.string().min(1).max(131_072) + }).strict().optional() + }).strict().optional(), summary: z.string().optional() }) export type ToolCallTurnItem = z.infer +export type ToolCallProviderMetadata = NonNullable export const ToolResultTurnItem = TurnItemBase.extend({ kind: z.literal('tool_result'), diff --git a/kun/src/contracts/model-request-trace.delegated.test.ts b/kun/src/contracts/model-request-trace.delegated.test.ts new file mode 100644 index 000000000..b6330bb98 --- /dev/null +++ b/kun/src/contracts/model-request-trace.delegated.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from 'vitest' +import { + MODEL_REQUEST_TRACE_SCHEMA_VERSION, + ModelRequestTraceRecordSchema +} from './model-request-trace.js' + +function record(delegated: unknown): Record { + return { + schemaVersion: MODEL_REQUEST_TRACE_SCHEMA_VERSION, + id: 'trace_1', + sequence: 1, + threadId: 'thread_1', + turnId: 'turn_1', + provider: 'claude-subscription', + model: 'claude-sonnet-4-5', + transport: 'sdk', + endpointFormat: 'agent-sdk', + attempt: 1, + attemptReason: 'initial', + status: 'completed', + startedAt: '2026-07-25T00:00:00.000Z', + request: { + method: 'SDK', + url: 'agent-sdk://local/query', + urlRedacted: false, + headers: { values: {}, redactedNames: [] }, + body: { + text: '{"input":"hello"}', + capturedBytes: 17, + originalBytes: 17, + truncated: false + } + }, + delegated + } +} + +describe('delegated model request trace contract', () => { + test('accepts the bounded non-secret delegated envelope', () => { + expect(ModelRequestTraceRecordSchema.parse(record({ + providerKind: 'agent-sdk', + phase: 'resumed', + contextManagement: 'sdk-managed', + nativeHistory: 'unknown', + capabilities: { + nativeResume: true, + structuredStreaming: true, + kunTools: true, + externalApproval: true, + liveSteering: false, + nativeContextTelemetry: false, + fork: false + } + })).delegated).toMatchObject({ + providerKind: 'agent-sdk', + phase: 'resumed', + nativeHistory: 'unknown' + }) + }) + + test('rejects raw native identifiers and incomplete capabilities', () => { + const parsed = ModelRequestTraceRecordSchema.parse(record({ + providerKind: 'cursor-sdk', + phase: 'rebased', + reason: 'native_state_unavailable', + contextManagement: 'sdk-managed', + nativeHistory: 'none', + nativeSessionId: 'must-not-survive', + capabilities: { + nativeResume: true, + structuredStreaming: true, + kunTools: false, + externalApproval: false, + liveSteering: false, + nativeContextTelemetry: false, + fork: false + } + })) + expect(parsed.delegated).not.toHaveProperty('nativeSessionId') + expect(() => ModelRequestTraceRecordSchema.parse(record({ + providerKind: 'agent-sdk', + phase: 'resumed', + contextManagement: 'sdk-managed', + nativeHistory: 'unknown', + capabilities: { nativeResume: true } + }))).toThrow() + }) +}) diff --git a/kun/src/contracts/model-request-trace.ts b/kun/src/contracts/model-request-trace.ts index 1730c3e69..12e2adca6 100644 --- a/kun/src/contracts/model-request-trace.ts +++ b/kun/src/contracts/model-request-trace.ts @@ -55,10 +55,44 @@ export const ModelRequestTraceToolCallSchema = z.object({ arguments: z.record(z.string(), z.unknown()) }) +export const ModelRequestTraceToolResultSchema = z.object({ + callId: z.string(), + toolName: z.string(), + output: z.string(), + isError: z.boolean() +}) + +export const ModelRequestTraceDelegatedCapabilitiesSchema = z.object({ + nativeResume: z.boolean(), + structuredStreaming: z.boolean(), + kunTools: z.boolean(), + externalApproval: z.boolean(), + liveSteering: z.boolean(), + nativeContextTelemetry: z.boolean(), + fork: z.boolean() +}) + +export const ModelRequestTraceDelegatedSchema = z.object({ + providerKind: z.enum(['agent-sdk', 'cursor-sdk', 'antigravity-cli']), + phase: z.enum(['portable', 'resumed', 'rebased']), + reason: z.enum([ + 'new', + 'route_changed', + 'capabilities_changed', + 'history_changed', + 'native_state_unavailable' + ]).optional(), + contextManagement: z.literal('sdk-managed'), + nativeHistory: z.enum(['known', 'unknown', 'none']), + capabilities: ModelRequestTraceDelegatedCapabilitiesSchema +}) +export type ModelRequestTraceDelegated = z.infer + export const ModelRequestTraceDecodedSchema = z.object({ text: z.string(), reasoning: z.string(), toolCalls: z.array(ModelRequestTraceToolCallSchema), + toolResults: z.array(ModelRequestTraceToolResultSchema).max(512).optional(), usage: UsageSnapshotSchema.optional(), stopReason: z.string().optional(), error: z.string().optional(), @@ -90,6 +124,7 @@ export const ModelRequestTraceRecordSchema = z.object({ timeToHeadersMs: z.number().nonnegative().optional(), durationMs: z.number().nonnegative().optional(), request: ModelRequestTraceRequestSchema, + delegated: ModelRequestTraceDelegatedSchema.optional(), toolCatalog: z.array(ModelRequestTraceToolCatalogEntrySchema) .max(MAX_MODEL_REQUEST_TRACE_TOOL_CATALOG_ENTRIES) .optional(), diff --git a/kun/src/delegation/child-agent-executor.test.ts b/kun/src/delegation/child-agent-executor.test.ts index 4237c37d9..4ff9dcdd4 100644 --- a/kun/src/delegation/child-agent-executor.test.ts +++ b/kun/src/delegation/child-agent-executor.test.ts @@ -213,6 +213,17 @@ describe('createChildAgentExecutor', () => { capturedBoundary = boundary return { handlesProvider: (providerId) => providerId === 'claude-subscription', + capabilities: (providerId) => providerId === 'claude-subscription' + ? { + nativeResume: true, + structuredStreaming: true, + kunTools: true, + externalApproval: true, + liveSteering: true, + nativeContextTelemetry: true, + fork: true + } + : undefined, runTurn: async (threadId, turnId) => { await boundary.turns.applyItem( threadId, diff --git a/kun/src/delegation/child-agent-executor.ts b/kun/src/delegation/child-agent-executor.ts index e0cac407b..753e97d93 100644 --- a/kun/src/delegation/child-agent-executor.ts +++ b/kun/src/delegation/child-agent-executor.ts @@ -10,7 +10,11 @@ import type { ApprovalPolicy, SandboxMode } from '../contracts/policy.js' import type { RuntimeTuningConfig } from '../config/kun-config.js' import { AgentLoop } from '../loop/agent-loop.js' import { normalizeRoleReasoningEffort } from '../loop/reasoning-effort.js' -import type { ContextCompactionConfig, ModelConfig } from '../loop/model-context-profile.js' +import type { + ContextCompactionConfig, + ModelConfig, + ModelContextProfile +} from '../loop/model-context-profile.js' import { ContextCompactor } from '../loop/context-compactor.js' import { InflightTracker } from '../loop/inflight-tracker.js' import { SteeringQueue } from '../loop/steering-queue.js' @@ -61,7 +65,10 @@ export type ChildAgentExecutorOptions = { tokenEconomy?: TokenEconomyConfig runtime?: RuntimeTuningConfig nowIso?: () => string - modelCapabilities?: (model: string) => ModelCapabilityMetadata + modelCapabilities?: (model: string, providerId?: string) => ModelCapabilityMetadata + profilesForProvider?: ( + providerId: string | undefined + ) => readonly ModelContextProfile[] skillRuntime?: SkillRuntime instructionRuntime?: InstructionRuntime memoryStore?: MemoryStore @@ -119,7 +126,8 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch const steering = new SteeringQueue() const compactor = new ContextCompactor({ contextCompaction: options.contextCompaction, - models: options.models + models: options.models, + profilesForProvider: options.profilesForProvider }) const turns = new TurnService({ threadStore, diff --git a/kun/src/domain/item.ts b/kun/src/domain/item.ts index 41c1fc466..9464f8703 100644 --- a/kun/src/domain/item.ts +++ b/kun/src/domain/item.ts @@ -1,4 +1,8 @@ -import type { TurnItem, UserMessageSource } from '../contracts/items.js' +import type { + ToolCallProviderMetadata, + TurnItem, + UserMessageSource +} from '../contracts/items.js' import type { ReviewOutput, ReviewTarget } from '../contracts/review.js' import type { UserInputQuestion } from '../ports/user-input-gate.js' import type { ComposerContextAttachmentJson } from '../contracts/composer-context.js' @@ -92,6 +96,7 @@ export function makeToolCallItem(input: { toolName: string toolKind?: 'tool_call' | 'command_execution' | 'file_change' arguments: Record + providerMetadata?: ToolCallProviderMetadata summary?: string status?: 'pending' | 'running' | 'completed' | 'failed' }): TurnItem { @@ -107,6 +112,7 @@ export function makeToolCallItem(input: { callId: input.callId, toolKind: input.toolKind ?? 'tool_call', arguments: input.arguments, + ...(input.providerMetadata ? { providerMetadata: input.providerMetadata } : {}), summary: input.summary } } diff --git a/kun/src/loop/agent-loop.test.ts b/kun/src/loop/agent-loop.test.ts index 72ba85412..ee67ce150 100644 --- a/kun/src/loop/agent-loop.test.ts +++ b/kun/src/loop/agent-loop.test.ts @@ -875,6 +875,22 @@ describe('resolvePlanModeToolSpecs', () => { expect(names).not.toContain('bash') }) + it('step 0: allows host-classified read-only MCP tools but not unknown calls', () => { + const tools: ModelToolSpec[] = [ + { ...spec('mcp_read_resource'), sideEffect: 'read-only', providerKind: 'mcp' }, + { ...spec('mcp_call'), providerKind: 'mcp' }, + spec('create_plan') + ] + const result = resolvePlanModeToolSpecs(tools, { + planTurnActive: true, + createPlanSatisfied: false, + stepIndex: 0, + readOnlyToolNames: new Set() + }) + + expect(result.map((tool) => tool.name)).toEqual(['mcp_read_resource', 'create_plan']) + }) + it('step > 0: only create_plan', () => { const result = resolvePlanModeToolSpecs(ALL_TOOLS, { planTurnActive: true, diff --git a/kun/src/loop/agent-loop.ts b/kun/src/loop/agent-loop.ts index e670e6416..1c56ee2c7 100644 --- a/kun/src/loop/agent-loop.ts +++ b/kun/src/loop/agent-loop.ts @@ -127,7 +127,7 @@ export type AgentLoopOptions = { ids: IdGenerator nowIso: () => string nowMs?: () => number - modelCapabilities?: (model: string) => ModelCapabilityMetadata + modelCapabilities?: (model: string, providerId?: string) => ModelCapabilityMetadata skillRuntime?: SkillRuntime instructionRuntime?: InstructionRuntime attachmentStore?: AttachmentStore @@ -532,6 +532,10 @@ export class AgentLoop { } await this.drainSteering(threadId, turnId, signal) await this.recordPipelineStage(threadId, turnId, 'post_start') + // Fire-and-forget: start LLM title generation as soon as the first-turn + // user message is in place, in parallel with the main reply. Only uses + // user input; never blocks the agent loop. + void this.threadTitle.generateAfterTurn(threadId, turnId, signal).catch(() => {}) if (delegatedSdkRuntime) { // The delegated SDK owns its model stream and cannot consume Kun's // native mid-turn queue. Drain anything that arrived before startup, @@ -546,9 +550,6 @@ export class AgentLoop { const settlement = await finalizer.observeExternal({ threadId, turnId }) finalStatus = statusFromSettlement(settlement, reportedStatus) finalError = errorFromSettlement(settlement) - if (finalStatus === 'completed') { - void this.threadTitle.generateAfterTurn(threadId, turnId, signal).catch(() => {}) - } return finalStatus } const status = await this.loop(threadId, turnId, signal) @@ -560,11 +561,6 @@ export class AgentLoop { }) finalStatus = statusFromSettlement(settlement, status) finalError = errorFromSettlement(settlement) - if (finalStatus === 'completed') { - // Fire-and-forget: generate an LLM title after the FIRST assistant - // reply completes, only when the thread still has a default title. - void this.threadTitle.generateAfterTurn(threadId, turnId, signal).catch(() => {}) - } return finalStatus } catch (error) { if (wallTimeExceeded) return failWallTimeLimit() @@ -697,7 +693,9 @@ export class AgentLoop { const limits = thread?.extensionBudget ? { ...configuredLimits, - maxSteps: Math.min(configuredLimits.maxSteps, thread.extensionBudget.maxModelRequests), + maxSteps: configuredLimits.maxSteps === undefined + ? thread.extensionBudget.maxModelRequests + : Math.min(configuredLimits.maxSteps, thread.extensionBudget.maxModelRequests), maxWallTimeMs: Math.min(configuredLimits.maxWallTimeMs, thread.extensionBudget.maxElapsedMs) } : configuredLimits @@ -707,10 +705,13 @@ export class AgentLoop { await this.drainAndSealSteering(threadId, turnId, signal) return 'aborted' } - if (step >= limits.maxSteps) { + if (limits.maxSteps !== undefined && step >= limits.maxSteps) { await this.drainAndSealSteering(threadId, turnId, signal) const extensionLimited = Boolean( - thread?.extensionBudget && thread.extensionBudget.maxModelRequests <= configuredLimits.maxSteps + thread?.extensionBudget && ( + configuredLimits.maxSteps === undefined || + thread.extensionBudget.maxModelRequests <= configuredLimits.maxSteps + ) ) await this.recordTurnLimitExceeded( threadId, diff --git a/kun/src/loop/context-compactor.test.ts b/kun/src/loop/context-compactor.test.ts index d789d01ce..dae9b02b0 100644 --- a/kun/src/loop/context-compactor.test.ts +++ b/kun/src/loop/context-compactor.test.ts @@ -1,10 +1,44 @@ import { describe, expect, it } from 'vitest' import { createImmutablePrefix } from '../cache/immutable-prefix.js' import type { TurnItem } from '../contracts/items.js' -import { makeAssistantTextItem, makeCompactionItem, makeUserItem } from '../domain/item.js' +import { + makeAssistantTextItem, + makeCompactionItem, + makeToolCallItem, + makeToolResultItem, + makeUserItem +} from '../domain/item.js' +import { repairModelHistoryItems } from '../domain/model-history-repair.js' import { ContextCompactor } from './context-compactor.js' +import { modelContextProfilesFromConfig } from './model-context-profile.js' describe('ContextCompactor', () => { + it('resolves same-id context thresholds from the active provider profile', () => { + const providerProfiles = modelContextProfilesFromConfig({ + profiles: { + shared: { contextWindowTokens: 1_000_000 } + } + }) + const compactor = new ContextCompactor({ + models: { + profiles: { + shared: { contextWindowTokens: 100_000 } + } + }, + profilesForProvider: (providerId) => + providerId === 'provider-b' ? providerProfiles : [] + }) + + expect(compactor.thresholds('shared')).toEqual({ + softThreshold: 75_000, + hardThreshold: 85_000 + }) + expect(compactor.thresholds('shared', 'provider-b')).toEqual({ + softThreshold: 750_000, + hardThreshold: 850_000 + }) + }) + it('does not replace an existing summary when no new history can be folded', () => { const threadId = 'thr_compaction_no_progress' const turnId = 'turn_compaction_no_progress' @@ -37,6 +71,125 @@ describe('ContextCompactor', () => { expect(result.next).toEqual([previousSummary, recent]) }) + it('retains a complete parallel tool batch when force compaction starts inside its results', () => { + const threadId = 'thr_single_turn_tools' + const turnId = 'turn_single_turn_tools' + const finalCallIds = ['call_final_a', 'call_final_b', 'call_final_c'] + const finalCalls = finalCallIds.map((callId) => + makeToolCallItem({ + id: `item_${callId}`, + threadId, + turnId, + callId, + toolName: 'read', + arguments: { path: `${callId}.ts` }, + status: 'completed' + }) + ) + const finalResults = finalCallIds.map((callId) => + makeToolResultItem({ + id: `result_${callId}`, + threadId, + turnId, + callId, + toolName: 'read', + output: `contents for ${callId}` + }) + ) + const history: TurnItem[] = [ + makeUserItem({ + id: 'item_user', + threadId, + turnId, + text: 'Inspect the repository with several tool batches.' + }), + makeAssistantTextItem({ + id: 'item_progress', + threadId, + turnId, + text: 'Earlier findings that can be summarized.', + status: 'completed' + }), + makeToolCallItem({ + id: 'item_old_call', + threadId, + turnId, + callId: 'call_old', + toolName: 'grep', + arguments: { pattern: 'old' }, + status: 'completed' + }), + makeToolResultItem({ + id: 'item_old_result', + threadId, + turnId, + callId: 'call_old', + toolName: 'grep', + output: 'old result' + }), + ...finalCalls, + ...finalResults + ] + + const result = new ContextCompactor().compact({ + threadId, + turnId, + history, + prefix: createImmutablePrefix(), + keepRecent: 1, + mode: 'force' + }) + const retainedIds = [...finalCalls, ...finalResults].map((item) => item.id) + + expect(result.next.map((item) => item.id)).toEqual([ + result.summaryItem.id, + ...retainedIds + ]) + expect(repairModelHistoryItems([...result.next])).toEqual(result.next) + expect(result.next.at(-1)).toMatchObject({ + kind: 'tool_result', + callId: 'call_final_c', + output: 'contents for call_final_c' + }) + expect(result.summaryItem.kind === 'compaction' ? result.summaryItem.sourceItemIds : []) + .toEqual(history.slice(0, 4).map((item) => item.id)) + }) + + it('cancels compaction when a retained tool result has no matching call', () => { + const threadId = 'thr_malformed_tools' + const turnId = 'turn_malformed_tools' + const history: TurnItem[] = [ + makeUserItem({ id: 'item_user', threadId, turnId, text: 'Keep this request.' }), + makeAssistantTextItem({ + id: 'item_answer', + threadId, + turnId, + text: 'Partial answer', + status: 'completed' + }), + makeToolResultItem({ + id: 'item_orphan_result', + threadId, + turnId, + callId: 'missing_call', + toolName: 'read', + output: 'orphaned output' + }) + ] + + const result = new ContextCompactor().compact({ + threadId, + turnId, + history, + prefix: createImmutablePrefix(), + keepRecent: 1, + mode: 'force' + }) + + expect(result.replacedTokens).toBe(0) + expect(result.next).toEqual(history) + }) + it('preserves numbered problem outlines when heuristic compaction is the fallback', () => { const threadId = 'thr_compaction_outline' const turnId = 'turn_compaction_outline' diff --git a/kun/src/loop/context-compactor.ts b/kun/src/loop/context-compactor.ts index a75d26dc9..9916175c0 100644 --- a/kun/src/loop/context-compactor.ts +++ b/kun/src/loop/context-compactor.ts @@ -38,6 +38,7 @@ export type CompactionPlan = { export type CompactionTriggerOptions = { model?: string + providerId?: string /** Provider-reported prompt token count for the last request, when known. */ promptTokens?: number frozenMessageCount?: number @@ -61,6 +62,9 @@ export class ContextCompactor { private readonly softThreshold: number private readonly hardThreshold: number private readonly modelProfiles: readonly ModelContextProfile[] + private readonly profilesForProvider?: ( + providerId: string | undefined + ) => readonly ModelContextProfile[] constructor(options?: { estimator?: ContextEstimator @@ -68,6 +72,9 @@ export class ContextCompactor { hardThreshold?: number contextCompaction?: ContextCompactionConfig models?: ModelConfig + profilesForProvider?: ( + providerId: string | undefined + ) => readonly ModelContextProfile[] }) { const contextCompaction = options?.contextCompaction this.estimator = options?.estimator ?? new ContextEstimator() @@ -83,6 +90,7 @@ export class ContextCompactor { contextCompaction, models: options?.models }) + this.profilesForProvider = options?.profilesForProvider } estimate(items: TurnItem[]): number { @@ -94,7 +102,7 @@ export class ContextCompactor { } planCompaction(items: TurnItem[], options?: CompactionTriggerOptions): CompactionPlan | null { - const thresholds = this.thresholds(options?.model) + const thresholds = this.thresholds(options?.model, options?.providerId) const frozenMessageCount = normalizeFrozenMessageCount(options?.frozenMessageCount, items.length) const compactableItems = frozenMessageCount > 0 ? items.slice(frozenMessageCount) : items // `overheadTokens` accounts for the system prompt and tool schemas that @@ -185,6 +193,21 @@ export class ContextCompactor { const tailStart = keepRecent === 0 ? history.length : repairTailStartForToolResults(history, history.length - keepRecent) + if (tailStart === 0) { + return { + next: [...frozen, ...history], + summaryItem: makeCompactionItem({ + id: `compaction_${input.turnId}_noop`, + turnId: input.turnId, + threadId: input.threadId, + summary: 'compaction skipped to preserve a complete tool interaction', + replacedTokens: 0, + pinnedConstraints: input.prefix.pinnedConstraints, + auto: input.auto + }), + replacedTokens: 0 + } + } const head = history.slice(0, tailStart) const tail = history.slice(tailStart) // Re-summarizing only the previous summary cannot reclaim any conversation @@ -244,15 +267,18 @@ export class ContextCompactor { } /** Hard cap used by the loop to enforce an upper bound on the conversation. */ - hardCap(model?: string): number { - return this.thresholds(model).hardThreshold + hardCap(model?: string, providerId?: string): number { + return this.thresholds(model, providerId).hardThreshold } - thresholds(model?: string): ModelContextThresholds { + thresholds(model?: string, providerId?: string): ModelContextThresholds { + const profiles = providerId + ? this.profilesForProvider?.(providerId) ?? this.modelProfiles + : this.modelProfiles return contextThresholdsForModel(model, { softThreshold: this.softThreshold, hardThreshold: this.hardThreshold - }, this.modelProfiles) + }, profiles) } } @@ -267,21 +293,70 @@ export function trimTrailingToolCalls(history: TurnItem[]): TurnItem[] { } function repairTailStartForToolResults(history: TurnItem[], start: number): number { - const tailStart = Math.max(0, Math.min(history.length, start)) - const tail = history.slice(tailStart) - if (!hasOrphanToolResult(tail)) return tailStart - for (let index = tailStart - 1; index >= 0; index -= 1) { - if (history[index].kind === 'user_message') return index > 0 ? index : tailStart + let tailStart = Math.max(0, Math.min(history.length, start)) + while (tailStart > 0) { + const orphanCallIds = orphanToolResultCallIds(history.slice(tailStart)) + if (orphanCallIds.length === 0) return tailStart + + const latestUserStart = findLatestUserMessageBefore(history, tailStart) + if (latestUserStart > 0) return latestUserStart + + let expandedStart = tailStart + for (const callId of orphanCallIds) { + const callIndex = findMatchingToolCallBefore(history, callId, tailStart) + if (callIndex < 0) { + // The persisted history is already malformed. Leave it unchanged + // instead of committing a compaction that would strand a result + // behind the summary and silently drop it during model-history repair. + return 0 + } + expandedStart = Math.min(expandedStart, toolCallBatchStart(history, callIndex)) + } + if (expandedStart >= tailStart) return 0 + tailStart = expandedStart } return tailStart } -function hasOrphanToolResult(items: TurnItem[]): boolean { +function findLatestUserMessageBefore(history: TurnItem[], before: number): number { + for (let index = Math.min(before, history.length) - 1; index >= 0; index -= 1) { + if (history[index].kind === 'user_message') return index + } + return -1 +} + +function orphanToolResultCallIds(items: TurnItem[]): string[] { const callIds = new Set() for (const item of items) { if (item.kind === 'tool_call') callIds.add(item.callId) } - return items.some((item) => item.kind === 'tool_result' && !callIds.has(item.callId)) + return [...new Set( + items + .filter((item): item is Extract => item.kind === 'tool_result') + .filter((item) => !callIds.has(item.callId)) + .map((item) => item.callId) + )] +} + +function findMatchingToolCallBefore(history: TurnItem[], callId: string, before: number): number { + for (let index = Math.min(before, history.length) - 1; index >= 0; index -= 1) { + const item = history[index] + if (item.kind === 'tool_call' && item.callId === callId) return index + } + return -1 +} + +function toolCallBatchStart(history: TurnItem[], callIndex: number): number { + const turnId = history[callIndex]?.turnId + let start = callIndex + while ( + start > 0 && + history[start - 1]?.kind === 'tool_call' && + history[start - 1]?.turnId === turnId + ) { + start -= 1 + } + return start } function aggressiveCompactionThreshold(thresholds: ModelContextThresholds): number { diff --git a/kun/src/loop/continuation-instructions.ts b/kun/src/loop/continuation-instructions.ts index 70bcb66d3..41daf198d 100644 --- a/kun/src/loop/continuation-instructions.ts +++ b/kun/src/loop/continuation-instructions.ts @@ -145,13 +145,23 @@ function escapeXmlText(value: string): string { } export function hasSuccessfulCreatePlanResult(items: readonly TurnItem[], turnId: string): boolean { - return items.some((item) => - item.turnId === turnId && - item.kind === 'tool_result' && - item.toolName === CREATE_PLAN_TOOL_NAME && - item.status === 'completed' && - item.isError !== true - ) + let satisfied = false + for (const item of items) { + if (item.turnId !== turnId) continue + if (item.kind === 'user_message') { + satisfied = false + continue + } + if ( + item.kind === 'tool_result' && + item.toolName === CREATE_PLAN_TOOL_NAME && + item.status === 'completed' && + item.isError !== true + ) { + satisfied = true + } + } + return satisfied } export function latestUserMessageText(items: readonly TurnItem[], turnId: string): string { diff --git a/kun/src/loop/history-compaction-service.ts b/kun/src/loop/history-compaction-service.ts index 7f92b0b21..cc8159ba1 100644 --- a/kun/src/loop/history-compaction-service.ts +++ b/kun/src/loop/history-compaction-service.ts @@ -71,6 +71,7 @@ export class HistoryCompactionService { }) const plan = this.deps.compactor.planCompaction(input.items, { model: thresholdModel, + providerId: input.providerId, promptTokens: pressure?.promptTokens, overheadTokens }) @@ -106,6 +107,7 @@ export class HistoryCompactionService { ? plan : this.deps.compactor.planCompaction(currentItems, { model: thresholdModel, + providerId: input.providerId, overheadTokens }) if (!currentPlan) { diff --git a/kun/src/loop/model-context-profile.test.ts b/kun/src/loop/model-context-profile.test.ts index d0e127dea..629b5b2c0 100644 --- a/kun/src/loop/model-context-profile.test.ts +++ b/kun/src/loop/model-context-profile.test.ts @@ -52,6 +52,28 @@ describe('contextThresholdsForModel safety cap', () => { const thresholds = contextThresholdsForModel('unknown-model', fallback, []) expect(thresholds).toEqual(fallback) }) + + it('derives safe thresholds from a Gemini context-window-only profile', () => { + const profiles = modelContextProfilesFromConfig({ + models: { + profiles: { + 'gemini-2.5-flash': { + contextWindowTokens: 1_048_576, + maxOutputTokens: 65_536 + } + } + } + }) + + expect(contextThresholdsForModel('gemini-2.5-flash', undefined, profiles)).toEqual({ + softThreshold: 786_432, + hardThreshold: 891_289 + }) + expect(modelCapabilitiesForModel('gemini-2.5-flash', profiles)).toMatchObject({ + contextWindowTokens: 1_048_576, + maxOutputTokens: 65_536 + }) + }) }) describe('per-model endpointFormat', () => { diff --git a/kun/src/loop/model-request-estimator.test.ts b/kun/src/loop/model-request-estimator.test.ts index 4f83a32e4..112f13afb 100644 --- a/kun/src/loop/model-request-estimator.test.ts +++ b/kun/src/loop/model-request-estimator.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { estimateModelRequestInputTokens } from './model-request-estimator.js' +import { + estimateModelRequestInputTokenBreakdown, + estimateModelRequestInputTokens +} from './model-request-estimator.js' +import { makeUserItem } from '../domain/item.js' import type { ModelRequest } from '../ports/model-client.js' describe('estimateModelRequestInputTokens', () => { @@ -37,4 +41,81 @@ describe('estimateModelRequestInputTokens', () => { threadProfileInstruction: 'p'.repeat(400) })).toBeGreaterThanOrEqual(estimateModelRequestInputTokens(base) + 100) }) + + it('partitions the final request into exact categories without redistributing the total', () => { + const skillInstruction = [ + '', + 's'.repeat(400), + '' + ].join('\n') + const request: ModelRequest = { + threadId: 'thr_breakdown', + turnId: 'turn_breakdown', + model: 'model', + systemPrompt: 'system '.repeat(40), + threadProfileInstruction: 'profile '.repeat(20), + modeInstruction: 'plan '.repeat(20), + contextInstructions: [ + 'runtime context '.repeat(30), + skillInstruction + ], + prefix: [makeUserItem({ + id: 'prefix_1', + turnId: 'turn_prefix', + threadId: 'thr_breakdown', + text: 'few shot '.repeat(30) + })], + history: [makeUserItem({ + id: 'history_1', + turnId: 'turn_breakdown', + threadId: 'thr_breakdown', + text: 'conversation '.repeat(50) + })], + tools: [{ + name: 'schema_heavy_tool', + description: 'tool description '.repeat(30), + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'schema field '.repeat(30) + } + } + } + }], + requiredToolName: 'schema_heavy_tool', + reasoningEffort: 'max', + abortSignal: new AbortController().signal + } + + const breakdown = estimateModelRequestInputTokenBreakdown(request, { + skillContextInstructions: [skillInstruction] + }) + const withoutSkillClassification = estimateModelRequestInputTokenBreakdown(request) + const withoutTools = estimateModelRequestInputTokenBreakdown({ + ...request, + tools: [] + }, { + skillContextInstructions: [skillInstruction] + }) + + expect(breakdown.tools).toBeGreaterThan(0) + expect(withoutTools.tools).toBe(0) + expect(breakdown.skills).toBeGreaterThan(0) + expect(breakdown.messages).toBeGreaterThan(0) + expect(breakdown.system).toBeGreaterThan(0) + expect(breakdown.other).toBeGreaterThan(0) + expect(withoutSkillClassification.skills).toBe(0) + expect(withoutSkillClassification.system).toBeGreaterThan(breakdown.system) + expect(withoutSkillClassification.total).toBe(breakdown.total) + expect(breakdown.total).toBe( + breakdown.tools + + breakdown.system + + breakdown.skills + + breakdown.messages + + breakdown.other + ) + expect(estimateModelRequestInputTokens(request)).toBe(breakdown.total) + }) }) diff --git a/kun/src/loop/model-request-estimator.ts b/kun/src/loop/model-request-estimator.ts index d5efce806..156806d38 100644 --- a/kun/src/loop/model-request-estimator.ts +++ b/kun/src/loop/model-request-estimator.ts @@ -12,21 +12,55 @@ const CHARS_PER_TOKEN = 4 const estimator = new ContextEstimator(CHARS_PER_TOKEN) +export type ModelRequestInputTokenBreakdown = { + tools: number + system: number + skills: number + messages: number + other: number + total: number +} + export function estimateModelRequestInputTokens(request: ModelRequest): number { - let tokens = 0 - tokens += estimateText(request.systemPrompt) - tokens += estimateText(request.threadProfileInstruction) - tokens += estimateText(request.modeInstruction) - tokens += estimateText(request.contextInstructions?.join('\n')) - tokens += estimateItems(request.prefix) - tokens += estimateItems(request.history) - tokens += estimateTools(request.tools) - tokens += estimateTextFallbacks(request.attachmentTextFallbacks) - tokens += estimateDocuments(request.attachmentDocuments) - tokens += estimateImageAttachments(request.attachments) - tokens += estimateText(request.requiredToolName) - tokens += estimateText(request.reasoningEffort) - return Math.max(0, tokens) + return estimateModelRequestInputTokenBreakdown(request).total +} + +/** + * Estimate the model-visible parts of one final request without mixing in + * provider billing counters or earlier requests from the same thread. + */ +export function estimateModelRequestInputTokenBreakdown( + request: ModelRequest, + options?: { skillContextInstructions?: readonly string[] } +): ModelRequestInputTokenBreakdown { + const { skill } = partitionContextInstructions( + request.contextInstructions, + options?.skillContextInstructions + ) + const contextInstructions = estimateText(request.contextInstructions?.join('\n')) + const skills = Math.min(contextInstructions, estimateText(skill.join('\n'))) + const nonSkillContext = contextInstructions - skills + const system = + estimateText(request.systemPrompt) + + estimateText(request.threadProfileInstruction) + + estimateText(request.modeInstruction) + + nonSkillContext + const messages = estimateItems(request.prefix) + estimateItems(request.history) + const tools = estimateTools(request.tools) + const other = + estimateTextFallbacks(request.attachmentTextFallbacks) + + estimateDocuments(request.attachmentDocuments) + + estimateImageAttachments(request.attachments) + + estimateText(request.requiredToolName) + + estimateText(request.reasoningEffort) + return { + tools, + system, + skills, + messages, + other, + total: tools + system + skills + messages + other + } } /** @@ -60,6 +94,31 @@ function estimateItems(items?: TurnItem[]): number { return items && items.length > 0 ? estimator.estimateItems(items) : 0 } +function partitionContextInstructions( + instructions: readonly string[] | undefined, + skillInstructions: readonly string[] | undefined +): { skill: string[]; nonSkill: string[] } { + if (!instructions?.length) return { skill: [], nonSkill: [] } + if (!skillInstructions?.length) return { skill: [], nonSkill: [...instructions] } + const remaining = new Map() + for (const instruction of skillInstructions) { + remaining.set(instruction, (remaining.get(instruction) ?? 0) + 1) + } + const skill: string[] = [] + const nonSkill: string[] = [] + for (const instruction of instructions) { + const count = remaining.get(instruction) ?? 0 + if (count > 0) { + skill.push(instruction) + if (count === 1) remaining.delete(instruction) + else remaining.set(instruction, count - 1) + } else { + nonSkill.push(instruction) + } + } + return { skill, nonSkill } +} + function estimateTools(tools?: readonly ModelToolSpec[]): number { if (!tools?.length) return 0 return tools.reduce((sum, tool) => { @@ -109,5 +168,5 @@ function estimateImageAttachments(attachments?: ModelInputAttachment[]): number function estimateText(text?: string): number { if (!text?.trim()) return 0 - return Math.max(1, Math.ceil(text.length / CHARS_PER_TOKEN)) + return Math.max(1, estimator.estimateText(text)) } diff --git a/kun/src/loop/model-round-engine.test.ts b/kun/src/loop/model-round-engine.test.ts index 61474aefd..d0acfa6a6 100644 --- a/kun/src/loop/model-round-engine.test.ts +++ b/kun/src/loop/model-round-engine.test.ts @@ -208,6 +208,29 @@ describe('ModelRoundEngine', () => { expect(new Set(toolCallItems.map((item) => item.id)).size).toBe(2) }) + it('persists provider-owned tool metadata without adding it to GUI runtime events', async () => { + const test = harness([ + { + kind: 'tool_call_complete', + callId: 'call_1', + toolName: 'read', + arguments: { path: 'file.ts' }, + providerMetadata: { + gemini: { thoughtSignature: 'opaque-provider-signature' } + } + }, + { kind: 'completed', stopReason: 'tool_calls' } + ]) + + await expect(test.run()).resolves.toEqual(expect.objectContaining({ kind: 'tool_calls' })) + expect(test.appliedItems.find((item) => item.kind === 'tool_call')).toMatchObject({ + providerMetadata: { + gemini: { thoughtSignature: 'opaque-provider-signature' } + } + }) + expect(JSON.stringify(test.recordedEvents)).not.toContain('opaque-provider-signature') + }) + it('allocates distinct runtime ids when one model step repeats a provider call id', async () => { const test = harness([ { kind: 'tool_call_complete', callId: 'call_shared', toolName: 'read', arguments: { path: 'a.ts' } }, diff --git a/kun/src/loop/model-round-engine.ts b/kun/src/loop/model-round-engine.ts index a91d1ff53..59a14ae1a 100644 --- a/kun/src/loop/model-round-engine.ts +++ b/kun/src/loop/model-round-engine.ts @@ -236,6 +236,9 @@ export class ModelRoundEngine { toolName: intent.call.toolName, toolKind: intent.call.toolKind, arguments: intent.call.arguments, + ...(intent.providerMetadata + ? { providerMetadata: intent.providerMetadata } + : {}), ...(intent.repairNotes.length ? { summary: `Repaired tool arguments: ${intent.repairNotes.join('; ')}` } : {}) diff --git a/kun/src/loop/model-step-service.ts b/kun/src/loop/model-step-service.ts index 31727dd68..b523e79bb 100644 --- a/kun/src/loop/model-step-service.ts +++ b/kun/src/loop/model-step-service.ts @@ -52,6 +52,7 @@ import { modelCapabilitiesForModel } from './model-context-profile.js' import type { ModelRoundEngine } from './model-round-engine.js' import { modelClientDiagnostics } from './model-client-diagnostics.js' import { composeModelRequest } from './model-request-composer.js' +import { estimateModelRequestInputTokenBreakdown } from './model-request-estimator.js' import type { ModelRoutingService } from './model-routing-service.js' import { PLAN_MODE_INSTRUCTION, @@ -106,7 +107,7 @@ export type ModelStepServiceDeps = { prefix: ImmutablePrefix ids: Pick nowIso: () => string - modelCapabilities?: (model: string) => ModelCapabilityMetadata + modelCapabilities?: (model: string, providerId?: string) => ModelCapabilityMetadata activePlanContext?: GuiPlanContext tokenEconomy?: TokenEconomyConfig toolArgumentRepair?: { maxStringBytes?: number } @@ -270,7 +271,8 @@ export class ModelStepService { ...(modelRoute.reasoningEffort ? { reasoningEffort: modelRoute.reasoningEffort } : {}) }) const model = modelRoute.model - const modelCapabilities = this.deps.modelCapabilities?.(model) ?? modelCapabilitiesForModel(model) + const modelCapabilities = + this.deps.modelCapabilities?.(model, providerId) ?? modelCapabilitiesForModel(model) const prepared = await this.deps.turnContextResolver.resolve({ threadId, turnId, @@ -541,6 +543,9 @@ export class ModelStepService { : []) ] const contextInstructions = buildKunTurnContextInstructions(contextBlocks) + const skillContextInstructions = buildKunTurnContextInstructions( + contextBlocks.filter((block) => block.authority === 'skill') + ).slice(1) await this.deps.recordPipelineStage(threadId, turnId, 'input_remembered', { memoryCount: memories.length, contextInstructionCount: contextInstructions.length @@ -572,6 +577,9 @@ export class ModelStepService { signal }) const { request, rawInputTokens, sentInputTokens, tokenEconomy } = composedRequest + const requestContext = estimateModelRequestInputTokenBreakdown(request, { + skillContextInstructions + }) const inputTokens = sentInputTokens const outputTokens = modelCapabilities.maxOutputTokens ?? 0 // A configured model context window is authoritative. ContextCompactor's @@ -580,7 +588,7 @@ export class ModelStepService { // metadata is unavailable. const hardCap = modelCapabilities.contextWindowTokens ? Math.floor(modelCapabilities.contextWindowTokens * 0.85) - : this.deps.compactor.hardCap(model) + : this.deps.compactor.hardCap(model, providerId) if (inputTokens + outputTokens > hardCap) { await this.deps.events.record({ kind: 'error', @@ -592,6 +600,32 @@ export class ModelStepService { }) return 'failed' } + const contextThresholds = this.deps.compactor.thresholds(model, providerId) + const contextWindowTokens = modelCapabilities.contextWindowTokens ?? + Math.max(contextThresholds.softThreshold, contextThresholds.hardThreshold) + await this.deps.events.record({ + kind: 'context_snapshot', + threadId, + turnId, + model: request.model, + ...(request.providerId ? { providerId: request.providerId } : {}), + stepIndex, + contextWindowTokens, + softThresholdTokens: contextThresholds.softThreshold, + hardThresholdTokens: contextThresholds.hardThreshold, + estimatedInputTokens: requestContext.total, + breakdown: { + tools: requestContext.tools, + system: requestContext.system, + skills: requestContext.skills, + messages: requestContext.messages, + other: requestContext.other + }, + toolCount: request.tools.length, + activeSkillIds: skillResolution.activeSkillIds, + contextManagement: 'kun-managed', + nativeHistory: 'none' + }) if (tokenEconomy.enabled) { await this.deps.recordTokenEconomySavings({ threadId, diff --git a/kun/src/loop/model-stream-collector.test.ts b/kun/src/loop/model-stream-collector.test.ts index 965c336c4..c07e8de58 100644 --- a/kun/src/loop/model-stream-collector.test.ts +++ b/kun/src/loop/model-stream-collector.test.ts @@ -46,7 +46,10 @@ describe('ModelStreamCollector', () => { kind: 'tool_call_complete', callId: 'call_1', toolName: 'edit', - arguments: { input: { path: 'src/a.ts' } } + arguments: { input: { path: 'src/a.ts' } }, + providerMetadata: { + gemini: { thoughtSignature: 'opaque-provider-signature' } + } }) const second = stream.reduce({ kind: 'tool_call_complete', @@ -58,6 +61,9 @@ describe('ModelStreamCollector', () => { expect(first.intents).toEqual([expect.objectContaining({ kind: 'tool_call_ready', repairNotes: ['flattened input wrapper'], + providerMetadata: { + gemini: { thoughtSignature: 'opaque-provider-signature' } + }, call: expect.objectContaining({ callId: 'call_1', providerId: 'builtin', diff --git a/kun/src/loop/model-stream-collector.ts b/kun/src/loop/model-stream-collector.ts index 731424d38..82a4ea637 100644 --- a/kun/src/loop/model-stream-collector.ts +++ b/kun/src/loop/model-stream-collector.ts @@ -1,4 +1,5 @@ import type { UsageSnapshot } from '../contracts/usage.js' +import type { ToolCallProviderMetadata } from '../contracts/items.js' import type { ModelStreamChunk } from '../ports/model-client.js' import type { ToolCallLike } from '../ports/tool-host.js' import { repairDispatchToolArguments } from './tool-call-repair.js' @@ -26,7 +27,12 @@ export type ModelStreamIntent = | { kind: 'assistant_text_delta'; text: string } | { kind: 'assistant_reasoning_delta'; text: string } | { kind: 'retrying'; status: number; attempt: number; maxAttempts: number; delayMs: number } - | { kind: 'tool_call_ready'; call: ToolCallLike; repairNotes: readonly string[] } + | { + kind: 'tool_call_ready' + call: ToolCallLike + repairNotes: readonly string[] + providerMetadata?: ToolCallProviderMetadata + } | { kind: 'generated_image'; imageBase64: string; mimeType: string } | { kind: 'usage'; usage: UsageSnapshot } | { kind: 'model_error'; message: string; code?: string } @@ -166,7 +172,8 @@ export class ModelStreamCollector { intents: [{ kind: 'tool_call_ready', call, - repairNotes: repaired.notes + repairNotes: repaired.notes, + ...(chunk.providerMetadata ? { providerMetadata: chunk.providerMetadata } : {}) }] } } diff --git a/kun/src/loop/plan-mode.ts b/kun/src/loop/plan-mode.ts index d2043013d..ec96dcf1f 100644 --- a/kun/src/loop/plan-mode.ts +++ b/kun/src/loop/plan-mode.ts @@ -16,7 +16,8 @@ export const PLAN_MODE_INSTRUCTION = [ 'Do NOT modify project files, apply edits, run shell commands, or run mutating commands in this mode.', 'If the request is ambiguous or hinges on a decision only the user can make, ask before planning: prefer the `user_input` tool to ask one concise round of clarifying questions (offer concrete options when there are any), then use the answer to write the plan in the same turn. If that tool is not available, end your turn with the question(s) in prose and wait for the answer. Either way, do NOT call `create_plan` until the ambiguity is resolved — a set of options the user still has to choose between is not a plan.', 'When you understand the task well enough, call the `create_plan` tool to save a complete implementation plan as Markdown.', - 'Use `operation: "draft"` for the first plan, and `operation: "refine"` when revising an existing plan; you may call `create_plan` multiple times as the plan evolves.', + 'Use `operation: "draft"` only when this thread has no associated plan or the user explicitly asks for a separate new plan. Use `operation: "refine"` for changes, adjustments, additions, or optimizations to the associated plan, and keep its exact `plan_id` and `plan_relative_path`.', + 'Never create `-2`, `-3`, or similar version-suffixed plan files to revise an associated plan; revise that plan in place.', 'Write concrete, actionable steps rather than vague intentions, and structure the saved Markdown with `##` section headings (e.g. Summary, Steps, Tests, Risks).', 'Favor the smallest plan that fully solves the task: question whether each proposed component, abstraction, dependency, config knob, or new file needs to exist at all (YAGNI), and prefer the standard library, a native platform feature, or an already-present dependency over new custom code. Do NOT trim correctness, input validation, error handling, security, or accessibility to make a plan smaller.', 'After saving, give the user a short summary of the plan and what to review.' @@ -69,7 +70,11 @@ export function resolvePlanModeToolSpecs( const planTool = options.planToolName ?? CREATE_PLAN_TOOL_NAME return options.stepIndex === 0 ? toolSpecs.filter( - (tool) => tool.name === planTool || readOnly.has(tool.name) || interactive.has(tool.name) + (tool) => + tool.name === planTool || + readOnly.has(tool.name) || + interactive.has(tool.name) || + tool.sideEffect === 'read-only' ) : toolSpecs.filter((tool) => tool.name === planTool) } diff --git a/kun/src/loop/thread-title-service.test.ts b/kun/src/loop/thread-title-service.test.ts new file mode 100644 index 000000000..73dc1b5da --- /dev/null +++ b/kun/src/loop/thread-title-service.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest' +import { InMemorySessionStore } from '../adapters/in-memory-session-store.js' +import { InMemoryThreadStore } from '../adapters/in-memory-thread-store.js' +import { createThreadRecord } from '../domain/thread.js' +import type { TurnItem } from '../contracts/items.js' +import type { ModelClient, ModelRequest, ModelStreamChunk } from '../ports/model-client.js' +import { ThreadTitleService } from './thread-title-service.js' + +function makeUserItem(threadId: string, turnId: string, text: string): TurnItem { + return { + id: `item_${turnId}_user`, + turnId, + threadId, + role: 'user', + status: 'completed', + createdAt: new Date().toISOString(), + finishedAt: new Date().toISOString(), + kind: 'user_message', + text + } +} + +function makeAssistantItem(threadId: string, turnId: string, text: string): TurnItem { + return { + id: `item_${turnId}_assistant`, + turnId, + threadId, + role: 'assistant', + status: 'completed', + createdAt: new Date().toISOString(), + finishedAt: new Date().toISOString(), + kind: 'assistant_text', + text + } +} + +function makeTitleModel(onRequest?: (request: ModelRequest) => void): ModelClient { + return { + provider: 'test', + model: 'main-model', + async *stream(request): AsyncIterable { + onRequest?.(request) + yield { kind: 'assistant_text_delta', text: 'LLM title' } + yield { kind: 'completed', stopReason: 'stop' } + } + } +} + +describe('ThreadTitleService', () => { + it('generates a title from user text while the first turn is still running', async () => { + const threadStore = new InMemoryThreadStore() + const sessionStore = new InMemorySessionStore() + const threadId = 'thr_title_early' + const turnId = 'turn_1' + let captured: ModelRequest | undefined + const recorded: Array<{ kind: string; title?: string }> = [] + + await threadStore.upsert(createThreadRecord({ + id: threadId, + title: '帮我做发版前最后review', + titleAuto: true, + workspace: '/tmp', + model: 'main-model' + })) + const thread = await threadStore.get(threadId) + await threadStore.upsert({ + ...thread!, + status: 'running', + turns: [{ + id: turnId, + threadId, + status: 'running', + prompt: '帮我做发版前最后review', + createdAt: new Date().toISOString(), + model: 'main-model', + steering: [], + items: [], + attachmentIds: [], + activeSkillIds: [], + injectedMemoryIds: [], + injectedMemorySummaries: [], + injectedInstructionSources: [] + }] + }) + await sessionStore.appendItem(threadId, makeUserItem(threadId, turnId, '帮我做发版前最后review')) + await sessionStore.appendItem(threadId, makeAssistantItem(threadId, turnId, 'I will start reviewing.')) + + const service = new ThreadTitleService({ + threadStore, + sessionStore, + model: makeTitleModel((request) => { + captured = request + }), + events: { + async record(event) { + recorded.push({ kind: event.kind, title: 'title' in event ? event.title : undefined }) + return { + ...event, + seq: recorded.length, + timestamp: new Date().toISOString() + } + } + }, + nowIso: () => new Date().toISOString(), + getRoles: () => undefined + }) + + await service.generateAfterTurn(threadId, turnId) + + const updated = await threadStore.get(threadId) + expect(updated).toMatchObject({ title: 'LLM title', titleAuto: true }) + expect(recorded).toContainEqual({ kind: 'thread_updated', title: 'LLM title' }) + expect(JSON.stringify(captured?.history)).toContain('帮我做发版前最后review') + expect(JSON.stringify(captured?.history)).not.toContain('I will start reviewing.') + expect(JSON.stringify(captured?.history)).not.toContain('Assistant reply') + }) + + it('skips once any turn has already completed', async () => { + const threadStore = new InMemoryThreadStore() + const sessionStore = new InMemorySessionStore() + const threadId = 'thr_title_skip' + const turnId = 'turn_2' + let called = 0 + + await threadStore.upsert(createThreadRecord({ + id: threadId, + title: 'Provisional', + titleAuto: true, + workspace: '/tmp', + model: 'main-model' + })) + const thread = await threadStore.get(threadId) + await threadStore.upsert({ + ...thread!, + status: 'running', + turns: [ + { + id: 'turn_1', + threadId, + status: 'completed', + prompt: 'first message', + createdAt: new Date().toISOString(), + finishedAt: new Date().toISOString(), + model: 'main-model', + steering: [], + items: [], + attachmentIds: [], + activeSkillIds: [], + injectedMemoryIds: [], + injectedMemorySummaries: [], + injectedInstructionSources: [] + }, + { + id: turnId, + threadId, + status: 'running', + prompt: 'second message', + createdAt: new Date().toISOString(), + model: 'main-model', + steering: [], + items: [], + attachmentIds: [], + activeSkillIds: [], + injectedMemoryIds: [], + injectedMemorySummaries: [], + injectedInstructionSources: [] + } + ] + }) + await sessionStore.appendItem(threadId, makeUserItem(threadId, turnId, 'second message')) + + const service = new ThreadTitleService({ + threadStore, + sessionStore, + model: makeTitleModel(() => { + called += 1 + }), + events: { + async record(event) { + return { + ...event, + seq: 1, + timestamp: new Date().toISOString() + } + } + }, + nowIso: () => new Date().toISOString(), + getRoles: () => undefined + }) + + await service.generateAfterTurn(threadId, turnId) + + expect(called).toBe(0) + const updated = await threadStore.get(threadId) + expect(updated?.title).toBe('Provisional') + }) +}) diff --git a/kun/src/loop/thread-title-service.ts b/kun/src/loop/thread-title-service.ts index 01a430168..2da87ddfc 100644 --- a/kun/src/loop/thread-title-service.ts +++ b/kun/src/loop/thread-title-service.ts @@ -28,13 +28,14 @@ export class ThreadTitleService { ): Promise { const thread = await this.deps.threadStore.get(threadId) if (!thread) return - if (thread.turns.filter((turn) => turn.status === 'completed').length > 1) return + // Skip once any turn has already completed — title runs in parallel with + // the first turn, so later turn starts must not re-title. + if (thread.turns.some((turn) => turn.status === 'completed')) return if (!canUpgradeThreadTitle(thread)) return const items = await this.deps.sessionStore.loadItems(threadId) const userText = items.find((item) => item.kind === 'user_message')?.text ?? '' if (!userText.trim()) return - const assistantText = items.find((item) => item.kind === 'assistant_text')?.text const roles = this.deps.getRoles() const resolved = resolveRoleModel({ roleModel: roles?.titleModel, @@ -55,7 +56,6 @@ export class ThreadTitleService { ...(resolved.providerId ? { providerId: resolved.providerId } : {}), ...(resolved.accountId ? { accountId: resolved.accountId } : {}), userText, - ...(assistantText ? { assistantText } : {}), ...(roles?.titleReasoningEffort ? { reasoningEffort: roles.titleReasoningEffort } : {}), diff --git a/kun/src/loop/title-generator.test.ts b/kun/src/loop/title-generator.test.ts index a046e5e4e..412dd8023 100644 --- a/kun/src/loop/title-generator.test.ts +++ b/kun/src/loop/title-generator.test.ts @@ -79,4 +79,31 @@ describe('generateThreadTitle', () => { }) expect(JSON.stringify(captured?.history)).not.toContain('account-private') }) + + it('builds the title prompt from user input only', async () => { + let captured: ModelRequest | undefined + const modelClient: ModelClient = { + provider: 'test', + model: 'test-model', + async *stream(request): AsyncIterable { + captured = request + yield { kind: 'assistant_text_delta', text: 'Release review' } + yield { kind: 'completed', stopReason: 'stop' } + } + } + + await expect(generateThreadTitle({ + threadId: 'thread_title', + turnId: 'turn_title', + modelClient, + model: 'title-model', + userText: '帮我做发版前最后review' + })).resolves.toBe('Release review') + + const historyText = JSON.stringify(captured?.history) + expect(historyText).toContain('User message:') + expect(historyText).toContain('帮我做发版前最后review') + expect(historyText).not.toContain('Assistant reply') + expect(captured?.turnId).toBe('turn_title_title') + }) }) diff --git a/kun/src/loop/title-generator.ts b/kun/src/loop/title-generator.ts index d8a7e5b43..f06fafb1d 100644 --- a/kun/src/loop/title-generator.ts +++ b/kun/src/loop/title-generator.ts @@ -77,8 +77,6 @@ export async function generateThreadTitle(input: { systemPrompt?: string /** First user message text (intent). Required for a meaningful title. */ userText: string - /** First assistant reply text. Optional supporting context. */ - assistantText?: string /** Reasoning depth for the title call. Invalid/missing => 'off'. */ reasoningEffort?: string timeoutMs?: number @@ -95,7 +93,7 @@ export async function generateThreadTitle(input: { input.abortSignal?.addEventListener('abort', onAbort, { once: true }) try { - const promptText = buildTitlePrompt(userText, input.assistantText) + const promptText = buildTitlePrompt(userText) const requestItem: TurnItem = { id: `item_${input.turnId}_title_request`, turnId: input.turnId, @@ -139,14 +137,13 @@ export async function generateThreadTitle(input: { } } -function buildTitlePrompt(userText: string, assistantText?: string): string { - const lines = ['User message:', clip(userText, MAX_TITLE_INPUT_CHARS)] - const assistant = trim(assistantText) - if (assistant) { - lines.push('', 'Assistant reply (for context only):', clip(assistant, 1_000)) - } - lines.push('', `Title (single line, <= ${MAX_TITLE_CHARS} chars):`) - return lines.join('\n') +function buildTitlePrompt(userText: string): string { + return [ + 'User message:', + clip(userText, MAX_TITLE_INPUT_CHARS), + '', + `Title (single line, <= ${MAX_TITLE_CHARS} chars):` + ].join('\n') } /** Strip quotes/markdown/leading "Title:" and clamp to the char cap. */ diff --git a/kun/src/loop/tool-execution-service.test.ts b/kun/src/loop/tool-execution-service.test.ts index b9a9755af..57d90e6a6 100644 --- a/kun/src/loop/tool-execution-service.test.ts +++ b/kun/src/loop/tool-execution-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { makeToolResultItem } from '../domain/item.js' +import type { TurnItem } from '../contracts/items.js' import type { ToolHost, ToolHostContext, ToolHostResult } from '../ports/tool-host.js' import type { RuntimeEventRecorder } from '../services/runtime-event-recorder.js' import type { TurnService } from '../services/turn-service.js' @@ -128,4 +129,47 @@ describe('ToolExecutionService', () => { expect.objectContaining({ kind: 'tool_storm_suppressed', message: 'duplicate call' }) ])) }) + + it('drains in-flight progress and ignores updates after tool execution completes', async () => { + let emitUpdate: ((item: TurnItem) => Promise | void) | undefined + const runningItem = makeToolResultItem({ + id: 'item_call_1', + threadId: 'thread_1', + turnId: 'turn_1', + callId: 'call_1', + toolName: 'read', + output: { partial: true }, + status: 'running' + }) + const { service, lifecycle } = makeService({ + execute: async (_call, _context, onUpdate) => { + emitUpdate = onUpdate + void onUpdate?.(runningItem) + return { + item: makeToolResultItem({ + id: 'item_call_1', + threadId: 'thread_1', + turnId: 'turn_1', + callId: 'call_1', + toolName: 'read', + output: { completed: true } + }), + approved: true + } + } + }) + + const result = await service.executeSafely({ + threadId: 'thread_1', + turnId: 'turn_1', + call, + context + }) + + expect(result.item).toMatchObject({ kind: 'tool_result', status: 'completed' }) + expect(lifecycle).toEqual(['update', 'apply']) + + await emitUpdate?.(runningItem) + expect(lifecycle).toEqual(['update', 'apply']) + }) }) diff --git a/kun/src/loop/tool-execution-service.ts b/kun/src/loop/tool-execution-service.ts index f9629b261..37e74b743 100644 --- a/kun/src/loop/tool-execution-service.ts +++ b/kun/src/loop/tool-execution-service.ts @@ -131,15 +131,36 @@ export class ToolExecutionService { }, async () => { try { - return await this.deps.toolHost.execute(input.call, input.context, async (item) => { - const existing = await this.deps.turns.updateItem(input.threadId, item.id, { - output: item.kind === 'tool_result' ? item.output : undefined, - isError: item.kind === 'tool_result' ? item.isError : undefined, - status: 'running' - } as Partial) - if (existing) return - await this.deps.turns.applyItem(input.threadId, item) - }) + let acceptingUpdates = true + let updateFailure: unknown + let pendingUpdates = Promise.resolve() + let result: ToolHostResult + try { + result = await this.deps.toolHost.execute(input.call, input.context, (item) => { + if (!acceptingUpdates) return + const update = pendingUpdates.then(async () => { + const existing = await this.deps.turns.updateItem(input.threadId, item.id, { + output: item.kind === 'tool_result' ? item.output : undefined, + isError: item.kind === 'tool_result' ? item.isError : undefined, + status: 'running' + } as Partial) + if (existing) return + await this.deps.turns.applyItem(input.threadId, item) + }) + pendingUpdates = update.catch((error) => { + updateFailure ??= error + }) + return update + }) + } finally { + // Tool progress is scoped to the execute() promise. Detached work + // may keep a callback reference, but it must not regress an already + // completed tool_result back to "running". + acceptingUpdates = false + await pendingUpdates + } + if (updateFailure) throw updateFailure + return result } catch (error) { if (input.context.abortSignal.aborted || !isRecoverableToolDispatchError(error)) { throw error diff --git a/kun/src/loop/turn-attachment-service.test.ts b/kun/src/loop/turn-attachment-service.test.ts index 6f02093e2..b1d5307de 100644 --- a/kun/src/loop/turn-attachment-service.test.ts +++ b/kun/src/loop/turn-attachment-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { AttachmentContent, AttachmentStore } from '../attachments/attachment-store.js' +import type { ModelCapabilityMetadata } from '../contracts/capabilities.js' import { TurnAttachmentService, imageGenerationReferenceInstructions @@ -33,6 +34,34 @@ function store(content: AttachmentContent): AttachmentStore { } as unknown as AttachmentStore } +function officeDocumentAttachment(overrides: Partial = {}): AttachmentContent { + return { + id: 'att_abcdef0123456789abcdef01', + name: 'book.xlsx', + kind: 'document', + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + byteSize: 8, + hash: 'a'.repeat(64), + sourceSha256: 'a'.repeat(64), + documentFormat: 'xlsx', + documentText: 'Sheet1\nA1 = 42\nA2 = =SUM(A1:A1)', + visualPreview: { + dataBase64: Buffer.from('preview').toString('base64'), + mimeType: 'image/webp', + byteSize: 7, + width: 800, + height: 600, + wasCompressed: true + }, + threadIds: ['thread_1'], + workspaces: ['/workspace'], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + data: Buffer.from('workbook'), + ...overrides + } +} + describe('TurnAttachmentService', () => { it('materializes image bytes only for image-capable models', async () => { const service = new TurnAttachmentService(store(imageAttachment())) @@ -78,6 +107,57 @@ describe('TurnAttachmentService', () => { }) }) + it('sends Office semantics to every model and adds its preview only for visual models', async () => { + const content = officeDocumentAttachment() + const service = new TurnAttachmentService(store(content)) + const textModel: ModelCapabilityMetadata = { + id: 'text', + inputModalities: ['text'], + outputModalities: ['text'], + supportsToolCalling: true, + messageParts: ['text'] + } + + await expect(service.resolveTurnAttachments({ + attachmentIds: [content.id], + threadId: 'thread_1', + workspace: '/workspace', + modelCapabilities: textModel + })).resolves.toEqual({ + imageAttachments: [], + textFallbacks: [], + documents: [expect.objectContaining({ + id: content.id, + documentFormat: 'xlsx', + sourceSha256: content.sourceSha256, + text: expect.stringContaining('A1 = 42') + })] + }) + + await expect(service.resolveTurnAttachments({ + attachmentIds: [content.id], + threadId: 'thread_1', + workspace: '/workspace', + modelCapabilities: { + ...textModel, + id: 'vision', + inputModalities: ['text', 'image'], + messageParts: ['text', 'image_url'] + } + })).resolves.toEqual({ + imageAttachments: [expect.objectContaining({ + id: `${content.id}_preview`, + mimeType: 'image/webp', + dataBase64: content.visualPreview?.dataBase64 + })], + textFallbacks: [], + documents: [expect.objectContaining({ + id: content.id, + documentFormat: 'xlsx' + })] + }) + }) + it('uses the authorized attachment before a recorded file fallback', async () => { const content = imageAttachment({ data: Buffer.from([0x89, 0x50, 0x4e, 0x47]) }) const attachmentStore = store(content) diff --git a/kun/src/loop/turn-attachment-service.ts b/kun/src/loop/turn-attachment-service.ts index 8e867d016..9c376872a 100644 --- a/kun/src/loop/turn-attachment-service.ts +++ b/kun/src/loop/turn-attachment-service.ts @@ -70,10 +70,34 @@ export class TurnAttachmentService { mimeType: attachment.mimeType, text, byteSize: attachment.byteSize, + ...(attachment.documentFormat ? { documentFormat: attachment.documentFormat } : {}), + ...(attachment.sourceSha256 ? { sourceSha256: attachment.sourceSha256 } : {}), ...(attachment.pageCount ? { pageCount: attachment.pageCount } : {}), ...(attachment.truncated || text.length < fullText.length ? { truncated: true } : {}), ...(attachment.localFilePath ? { localFilePath: attachment.localFilePath } : {}) }) + if (supportsImageInput && attachment.visualPreview) { + const preview = attachment.visualPreview + const previewBase64Bytes = Buffer.byteLength(preview.dataBase64, 'utf8') + if (previewBase64Bytes > textFallbackPolicy.textFallbackMaxBase64Bytes) { + throw new Error( + `attachment ${attachment.id} visual preview exceeds ${textFallbackPolicy.textFallbackMaxBase64Bytes} base64 byte limit` + ) + } + totalAttachmentBytes += preview.byteSize + if (totalAttachmentBytes > MAX_TURN_ATTACHMENT_BYTES) { + throw new Error(`turn attachments exceed ${MAX_TURN_ATTACHMENT_BYTES} byte limit`) + } + imageAttachments.push({ + id: `${attachment.id}_preview`, + name: `${attachment.name} preview`, + mimeType: preview.mimeType, + dataBase64: preview.dataBase64, + ...(preview.width ? { width: preview.width } : {}), + ...(preview.height ? { height: preview.height } : {}), + ...(attachment.localFilePath ? { localFilePath: attachment.localFilePath } : {}) + }) + } if (remainingDocumentChars <= 0) break continue } diff --git a/kun/src/loop/turn-limits.test.ts b/kun/src/loop/turn-limits.test.ts new file mode 100644 index 000000000..90c0e0a1f --- /dev/null +++ b/kun/src/loop/turn-limits.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { normalizeTurnLimits } from './turn-limits.js' + +describe('normalizeTurnLimits', () => { + it('leaves model steps unlimited by default', () => { + expect(normalizeTurnLimits(undefined)).toEqual({ + maxWallTimeMs: 15 * 60_000, + maxToolCallsPerStep: 10_000 + }) + }) + + it('normalizes an explicitly configured model-step limit', () => { + expect(normalizeTurnLimits({ maxSteps: 7.9 }).maxSteps).toBe(7) + }) +}) diff --git a/kun/src/loop/turn-limits.ts b/kun/src/loop/turn-limits.ts index 8021d9995..3be871328 100644 --- a/kun/src/loop/turn-limits.ts +++ b/kun/src/loop/turn-limits.ts @@ -4,12 +4,18 @@ export type TurnLimitsConfig = { maxToolCallsPerStep?: number } -export type NormalizedTurnLimits = Required +export type NormalizedTurnLimits = { + maxSteps?: number + maxWallTimeMs: number + maxToolCallsPerStep: number +} export function normalizeTurnLimits(input: TurnLimitsConfig | undefined): NormalizedTurnLimits { return { - maxSteps: Math.max(1, Math.floor(input?.maxSteps ?? 64)), + ...(input?.maxSteps !== undefined + ? { maxSteps: Math.max(1, Math.floor(input.maxSteps)) } + : {}), maxWallTimeMs: Math.max(1, Math.floor(input?.maxWallTimeMs ?? 15 * 60_000)), - maxToolCallsPerStep: Math.max(1, Math.floor(input?.maxToolCallsPerStep ?? 32)) + maxToolCallsPerStep: Math.max(1, Math.floor(input?.maxToolCallsPerStep ?? 10_000)) } } diff --git a/kun/src/ports/model-client.ts b/kun/src/ports/model-client.ts index 1bc958af4..19ff0ce3a 100644 --- a/kun/src/ports/model-client.ts +++ b/kun/src/ports/model-client.ts @@ -1,4 +1,4 @@ -import type { TurnItem } from '../contracts/items.js' +import type { ToolCallProviderMetadata, TurnItem } from '../contracts/items.js' import type { UsageSnapshot } from '../contracts/usage.js' import type { ToolProviderKind } from './tool-host.js' import type { ModelFailureMetadata } from '../contracts/model-route-pool.js' @@ -20,7 +20,13 @@ export type ModelStreamChunk = ( | { kind: 'assistant_text_delta'; text: string } | { kind: 'assistant_reasoning_delta'; text: string } | { kind: 'tool_call_delta'; callId: string; toolName?: string; argumentsDelta?: string } - | { kind: 'tool_call_complete'; callId: string; toolName: string; arguments: Record } + | { + kind: 'tool_call_complete' + callId: string + toolName: string + arguments: Record + providerMetadata?: ToolCallProviderMetadata + } | { kind: 'retrying'; status: number; attempt: number; maxAttempts: number; delayMs: number } | { kind: 'image_generation_complete'; imageBase64: string; mimeType: string } | { kind: 'usage'; usage: UsageSnapshot } @@ -125,6 +131,8 @@ export type ModelDocumentAttachment = { mimeType: string text: string byteSize: number + documentFormat?: 'pdf' | 'docx' | 'xlsx' | 'pptx' | 'text' | 'csv' | 'json' | 'xml' + sourceSha256?: string pageCount?: number truncated?: boolean localFilePath?: string @@ -135,6 +143,8 @@ export type ModelToolSpec = { description: string inputSchema: Record toolKind?: 'tool_call' | 'command_execution' | 'file_change' + /** Host-authored side-effect classification; never forwarded to model providers. */ + sideEffect?: 'read-only' | 'unknown' /** Local execution provenance. Provider serializers must not forward it. */ providerKind?: ToolProviderKind /** Stable local provider id (for example `builtin` or `mcp:filesystem`). */ diff --git a/kun/src/prompt/kun-system-prompt.test.ts b/kun/src/prompt/kun-system-prompt.test.ts index 29fb3c7f7..1fc879729 100644 --- a/kun/src/prompt/kun-system-prompt.test.ts +++ b/kun/src/prompt/kun-system-prompt.test.ts @@ -125,6 +125,73 @@ describe('buildToolPreferenceInstruction', () => { expect(buildToolPreferenceInstruction([...tools].reverse())).toBe(instruction) }) + it('adds bounded delegation guidance only when the child-agent tool is available', () => { + const instruction = buildToolPreferenceInstruction([ + { name: 'delegate_task', description: 'Run a standalone child agent' } + ]) + + expect(instruction).toContain('specialist expertise') + expect(instruction).toContain('fresh independent review') + expect(instruction).toContain('parallel investigation of independent workstreams') + expect(instruction).toContain('keep integration and final verification in the parent agent') + expect(instruction).toContain('Do not delegate trivial work') + }) + + it('explains only exact-profile and automatic routes in existing-profile mode', () => { + const instruction = buildToolPreferenceInstruction([ + { name: 'list_subagent_profiles', description: 'List reusable roles' }, + { + name: 'delegate_task', + description: 'Run a standalone child agent', + inputSchema: { + type: 'object', + properties: { + prompt: { type: 'string' }, + profile: { type: 'string' } + } + } + } + ]) + + expect(instruction).toContain('exact roster knowledge') + expect(instruction).toContain('exact returned `profile` id') + expect(instruction).toContain('omit `profile` for automatic routing') + expect(instruction).not.toContain('`custom_agent`') + expect(instruction).not.toContain('security-auditor') + }) + + it('explains only custom roles in custom-only mode', () => { + const instruction = buildToolPreferenceInstruction([ + { name: 'list_subagent_profiles', description: 'Describe custom roles' }, + { + name: 'delegate_task', + description: 'Run a standalone child agent', + inputSchema: { + type: 'object', + properties: { + prompt: { type: 'string' }, + custom_agent: { type: 'object' } + } + } + } + ]) + + expect(instruction).toContain('`custom_agent`') + expect(instruction).toContain('reusable profile selection and automatic catalog routing are unavailable') + expect(instruction).not.toContain('exact returned `profile` id') + expect(instruction).not.toContain('omit `profile` for automatic routing') + }) + + it('keeps read-only profile discovery useful when child execution is not advertised', () => { + const instruction = buildToolPreferenceInstruction([ + { name: 'list_subagent_profiles', description: 'List custom and reusable roles' } + ]) + + expect(instruction).toContain('while planning') + expect(instruction).toContain('does not create a child run') + expect(instruction).not.toContain('Issue multiple child calls') + }) + it('prefers specialized MCP source navigation with available built-in fallback', () => { const instruction = buildToolPreferenceInstruction([ { name: 'grep', description: 'Search file contents' }, diff --git a/kun/src/prompt/kun-system-prompt.ts b/kun/src/prompt/kun-system-prompt.ts index b183e6a05..1b8548ddb 100644 --- a/kun/src/prompt/kun-system-prompt.ts +++ b/kun/src/prompt/kun-system-prompt.ts @@ -49,6 +49,7 @@ type ToolPreferenceSpec = { name: string description: string providerKind?: string + inputSchema?: Record } const SOURCE_EXPLORATION_PATTERN = @@ -131,6 +132,37 @@ export function buildToolPreferenceInstruction( ) } + if (names.has('delegate_task')) { + const delegateTool = sortedTools.find((tool) => tool.name === 'delegate_task') + const profileAdvertised = hasInputProperty(delegateTool, 'profile') + const customAgentAdvertised = hasInputProperty(delegateTool, 'custom_agent') + bullets.push( + 'Use `delegate_task` when a substantial task benefits from specialist expertise, a fresh independent review, or parallel investigation of independent workstreams. Delegate a clear bounded outcome with enough context; keep integration and final verification in the parent agent.' + ) + bullets.push( + 'Do not delegate trivial work, tightly coupled sequential steps, or tasks the parent can complete faster directly. Issue multiple child calls together only when they are genuinely independent.' + ) + if (names.has('list_subagent_profiles')) { + if (profileAdvertised) { + bullets.push( + 'Use `list_subagent_profiles` only when exact roster knowledge would change task decomposition or profile selection. Pass an exact returned `profile` id, or omit `profile` for automatic routing over the effective reusable catalog.' + ) + } else if (customAgentAdvertised) { + bullets.push( + 'Use `list_subagent_profiles` only when the current one-run custom-role capability details would change task decomposition. Define the role with `custom_agent`; reusable profile selection and automatic catalog routing are unavailable in this mode.' + ) + } else { + bullets.push( + 'Use `list_subagent_profiles` only when the active subagent-mode details would change task decomposition or delegation.' + ) + } + } + } else if (names.has('list_subagent_profiles')) { + bullets.push( + 'Use `list_subagent_profiles` to inspect the active subagent mode while planning; the read-only discovery tool does not create a child run.' + ) + } + if (memoryTools.length > 0) { bullets.push( `Use ${formatToolNames(memoryTools)} only for durable user-approved facts or preferences, never for transient task state or content already available in the workspace.` @@ -168,6 +200,17 @@ function presentNames( return candidates.filter((name) => available.has(name)) } +function hasInputProperty( + tool: ToolPreferenceSpec | undefined, + property: string +): boolean { + const schema = tool?.inputSchema + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return false + const properties = schema.properties + if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return false + return Object.prototype.hasOwnProperty.call(properties, property) +} + function formatToolNames(names: readonly string[]): string { const visible = names.slice(0, 8).map((name) => `\`${name}\``).join(', ') const remaining = names.length - 8 diff --git a/kun/src/runtime/agent-sdk/agent-sdk-runtime-factory.test.ts b/kun/src/runtime/agent-sdk/agent-sdk-runtime-factory.test.ts index aa0277f7a..c6b1e5718 100644 --- a/kun/src/runtime/agent-sdk/agent-sdk-runtime-factory.test.ts +++ b/kun/src/runtime/agent-sdk/agent-sdk-runtime-factory.test.ts @@ -11,6 +11,11 @@ import { CapabilityRegistry } from '../../adapters/tool/capability-registry.js' import { LocalToolHost } from '../../adapters/tool/local-tool-host.js' import { InMemoryApprovalGate } from '../../adapters/in-memory-approval-gate.js' import { InMemoryUserInputGate } from '../../adapters/in-memory-user-input-gate.js' +import { + DelegatedSessionCoordinator, + FileDelegatedSessionBindingStore, + type DelegatedSessionPreparation +} from '../delegated-session-binding.js' function fakeGate(pending: Promise): { gate: UserInputGate @@ -111,13 +116,216 @@ describe('resolveTurnPlanContext', () => { }) }) +describe('createAgentSdkRuntime delegated session binding', () => { + test('restores a compatible Claude session and scopes OAuth state under Kun data', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-claude-binding-')) + try { + let items = [{ + id: 'item_t1', + turnId: 't1', + threadId: 'th', + kind: 'user_message', + role: 'user', + status: 'completed', + text: 'first', + createdAt: '2026-07-25T00:00:00.000Z' + }] + let thread = threadWith({ + id: 'th', + providerId: 'claude-subscription', + workspace: '/ws', + turns: [{ id: 't1', prompt: 'first' } as ThreadRecord['turns'][number]] + }) + const buildRuntime = (sessionCoordinator: DelegatedSessionCoordinator) => + createAgentSdkRuntime({ + registry: CapabilityRegistry.fromLocalTools([]), + turns: { updateTurnMetadata: async () => undefined } as never, + sessionStore: { loadItems: async () => items } as never, + threadStore: { get: async () => thread } as never, + events: {} as never, + ids: { next: (prefix) => prefix }, + prefix: { systemPrompt: 'Kun system prompt' }, + providerConfigs: { + 'claude-subscription': { kind: 'agent-sdk', apiKey: 'sk-ant-oat01-oauth-secret' } + } as never, + agentSdkProviderIds: new Set(['claude-subscription']), + defaultApprovalPolicy: 'auto', + sessionCoordinator + }) + const firstCoordinator = new DelegatedSessionCoordinator( + new FileDelegatedSessionBindingStore(root) + ) + const firstRuntime = buildRuntime(firstCoordinator) + const firstDeps = (firstRuntime as unknown as { + deps: { + loadTurnContext(threadId: string, turnId: string): Promise<{ + claudeConfigDir?: string + sessionPreparation?: DelegatedSessionPreparation + } | null> + } + }).deps + const first = await firstDeps.loadTurnContext('th', 't1') + expect(first?.claudeConfigDir).toContain('provider-state') + await firstCoordinator.commit({ + preparation: first!.sessionPreparation!, + committedItems: items as never, + lastCommittedTurnId: 't1', + nativeSessionId: 'session_persisted' + }) + + items = [...items, { + id: 'item_t2', + turnId: 't2', + threadId: 'th', + kind: 'user_message', + role: 'user', + status: 'completed', + text: 'second', + createdAt: '2026-07-25T00:01:00.000Z' + }] + thread = { + ...thread, + turns: [ + ...thread.turns, + { id: 't2', prompt: 'second' } as ThreadRecord['turns'][number] + ] + } + const restarted = buildRuntime(new DelegatedSessionCoordinator( + new FileDelegatedSessionBindingStore(root) + )) + const restartedDeps = (restarted as unknown as { + deps: { + loadTurnContext(threadId: string, turnId: string): Promise<{ + resumeSessionId?: string + historyTranscript?: string + } | null> + } + }).deps + const second = await restartedDeps.loadTurnContext('th', 't2') + expect(second?.resumeSessionId).toBe('session_persisted') + expect(second?.historyTranscript).toContain('first') + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test('rotates Claude continuation when a bridged tool changes provider identity', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-claude-tool-provider-')) + try { + let items = [{ + id: 'item_t1', + turnId: 't1', + threadId: 'th', + kind: 'user_message', + role: 'user', + status: 'completed', + text: 'first', + createdAt: '2026-07-25T00:00:00.000Z' + }] + let thread = threadWith({ + id: 'th', + providerId: 'claude-subscription', + turns: [{ id: 't1', prompt: 'first' } as ThreadRecord['turns'][number]] + }) + const coordinator = new DelegatedSessionCoordinator( + new FileDelegatedSessionBindingStore(root) + ) + const buildRegistry = (providerId: string) => new CapabilityRegistry([{ + id: providerId, + kind: 'mcp', + enabled: true, + available: true, + tools: [LocalToolHost.defineTool({ + name: 'remote_lookup', + description: 'Look up remote documentation', + inputSchema: { type: 'object' }, + sideEffect: 'read-only', + execute: async () => ({ output: 'ok' }) + })] + }]) + const buildRuntime = (registry: CapabilityRegistry) => createAgentSdkRuntime({ + registry, + toolHost: new LocalToolHost({ registry }), + turns: { updateTurnMetadata: async () => undefined } as never, + sessionStore: { loadItems: async () => items } as never, + threadStore: { get: async () => thread } as never, + events: {} as never, + ids: { next: (prefix) => prefix }, + prefix: { systemPrompt: 'Kun system prompt' }, + providerConfigs: { + 'claude-subscription': { kind: 'agent-sdk', apiKey: 'sk-ant-oat01-oauth-secret' } + } as never, + agentSdkProviderIds: new Set(['claude-subscription']), + defaultApprovalPolicy: 'auto', + sessionCoordinator: coordinator + }) + const firstRuntime = buildRuntime(buildRegistry('mcp:first')) + const firstDeps = (firstRuntime as unknown as { + deps: { + loadTurnContext(threadId: string, turnId: string): Promise<{ + sessionPreparation?: DelegatedSessionPreparation + } | null> + } + }).deps + const first = await firstDeps.loadTurnContext('th', 't1') + await coordinator.commit({ + preparation: first!.sessionPreparation!, + committedItems: items as never, + lastCommittedTurnId: 't1', + nativeSessionId: 'session_first_provider' + }) + + items = [...items, { + id: 'item_t2', + turnId: 't2', + threadId: 'th', + kind: 'user_message', + role: 'user', + status: 'completed', + text: 'second', + createdAt: '2026-07-25T00:01:00.000Z' + }] + thread = { + ...thread, + turns: [ + ...thread.turns, + { id: 't2', prompt: 'second' } as ThreadRecord['turns'][number] + ] + } + const secondRuntime = buildRuntime(buildRegistry('mcp:second')) + const secondDeps = (secondRuntime as unknown as { + deps: { + loadTurnContext(threadId: string, turnId: string): Promise<{ + resumeSessionId?: string + bridgeableTools: Array<{ providerId?: string }> + sessionPreparation?: DelegatedSessionPreparation + } | null> + } + }).deps + const second = await secondDeps.loadTurnContext('th', 't2') + + expect(second?.resumeSessionId).toBeUndefined() + expect(second?.sessionPreparation?.rebaseReason).toBe('capabilities_changed') + expect(second?.bridgeableTools).toContainEqual(expect.objectContaining({ + name: 'remote_lookup', + providerId: 'mcp:second', + providerKind: 'mcp' + })) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) + // handlesProvider only reads providerConfigs / agentSdkProviderIds / defaultIsAgentSdk, // so the heavy service deps can be stubbed for this routing test. function make(opts: { agentSdk: string[]; http: string[]; defaultIsAgentSdk: boolean }): { handlesProvider(id: string | undefined): boolean } { const providerConfigs: Record = {} - for (const id of opts.agentSdk) providerConfigs[id] = { kind: 'agent-sdk', apiKey: 'tok' } + for (const id of opts.agentSdk) { + providerConfigs[id] = { kind: 'agent-sdk', apiKey: 'sk-ant-oat01-tok' } + } for (const id of opts.http) providerConfigs[id] = { baseUrl: 'https://x', apiKey: 'key' } return createAgentSdkRuntime({ registry: {} as never, @@ -131,7 +339,7 @@ function make(opts: { agentSdk: string[]; http: string[]; defaultIsAgentSdk: boo agentSdkProviderIds: new Set(opts.agentSdk), defaultApprovalPolicy: 'auto', defaultIsAgentSdk: opts.defaultIsAgentSdk, - defaultToken: 'tok' + defaultToken: 'sk-ant-oat01-tok' }) } @@ -174,6 +382,196 @@ describe('createAgentSdkRuntime handlesProvider', () => { }) describe('createAgentSdkRuntime turn context', () => { + const credentialContext = async (options: { + providerId?: string + providerToken?: string + defaultToken?: string + }): Promise<{ oauthToken?: string } | null> => { + const runtime = createAgentSdkRuntime({ + registry: CapabilityRegistry.fromLocalTools([]), + turns: { updateTurnMetadata: async () => undefined } as never, + sessionStore: { + loadItems: async () => [{ + id: 'item_user', + turnId: 'tn', + threadId: 'th', + kind: 'user_message', + role: 'user', + status: 'completed', + text: 'check credentials', + createdAt: '2026-07-25T00:00:00.000Z' + }] + } as never, + threadStore: { + get: async () => threadWith({ + ...(options.providerId ? { providerId: options.providerId } : {}), + turns: [{ id: 'tn', prompt: 'check credentials' } as ThreadRecord['turns'][number]] + }) + } as never, + events: {} as never, + ids: { next: (prefix) => prefix }, + prefix: { systemPrompt: 'Kun system prompt' }, + providerConfigs: options.providerId + ? { + [options.providerId]: { + kind: 'agent-sdk', + apiKey: options.providerToken ?? '' + } + } as never + : {}, + agentSdkProviderIds: new Set(options.providerId ? [options.providerId] : []), + defaultApprovalPolicy: 'auto', + defaultIsAgentSdk: !options.providerId, + defaultToken: options.defaultToken + }) + const deps = (runtime as unknown as { + deps: { + loadTurnContext( + threadId: string, + turnId: string + ): Promise<{ oauthToken?: string } | null> + } + }).deps + return deps.loadTurnContext('th', 'tn') + } + + test('keeps an explicit Claude provider on ambient login instead of inheriting the default token', async () => { + const context = await credentialContext({ + providerId: 'claude-subscription', + providerToken: '', + defaultToken: 'sk-ant-oat01-unrelated-provider' + }) + expect(context?.oauthToken).toBeUndefined() + }) + + test('uses the default token only for the implicit default Agent SDK route', async () => { + const context = await credentialContext({ + defaultToken: 'sk-ant-oat01-default-agent-sdk' + }) + expect(context?.oauthToken).toBe('sk-ant-oat01-default-agent-sdk') + }) + + test('rejects an invalid explicit token without disclosing it', async () => { + const raw = 'Bearer sk-ant-oat01-private-value' + const loading = credentialContext({ + providerId: 'claude-subscription', + providerToken: raw + }) + await expect(loading).rejects.toThrow('Claude subscription token format is invalid') + await loading.catch((error) => { + expect(String(error)).not.toContain('sk-ant-oat01-private-value') + }) + }) + + test('prepares lazy extension tools and preserves MCP/extension provenance', async () => { + const extensionExecute = vi.fn(async () => ({ output: 'extension result' })) + const registry = new CapabilityRegistry([{ + id: 'mcp:docs', + kind: 'mcp', + enabled: true, + available: true, + tools: [LocalToolHost.defineTool({ + name: 'mcp_docs_lookup', + description: 'Look up MCP docs', + inputSchema: { type: 'object' }, + sideEffect: 'read-only', + execute: async () => ({ output: 'mcp result' }) + })] + }]) + let prepared = false + const host = new LocalToolHost({ + registry, + prepare: () => { + if (prepared) return + prepared = true + registry.registerProvider({ + id: 'extension:demo', + kind: 'extension', + enabled: true, + available: true, + tools: [LocalToolHost.defineTool({ + name: 'extension_render', + description: 'Render with an extension', + inputSchema: { type: 'object' }, + sideEffect: 'read-only', + execute: extensionExecute + })] + }) + } + }) + const runtime = createAgentSdkRuntime({ + registry, + toolHost: host, + turns: { updateTurnMetadata: async () => undefined } as never, + sessionStore: { + loadItems: async () => [{ + id: 'item_user', + turnId: 'tn', + threadId: 'th', + kind: 'user_message', + role: 'user', + status: 'completed', + text: 'use the configured tools', + createdAt: '2026-07-25T00:00:00.000Z' + }] + } as never, + threadStore: { + get: async () => threadWith({ + providerId: 'claude-subscription', + turns: [{ id: 'tn', prompt: 'use the configured tools' } as ThreadRecord['turns'][number]] + }) + } as never, + events: {} as never, + ids: { next: (prefix) => prefix }, + prefix: { systemPrompt: 'Kun system prompt' }, + providerConfigs: { + 'claude-subscription': { kind: 'agent-sdk', apiKey: 'sk-ant-oat01-tok' } + } as never, + agentSdkProviderIds: new Set(['claude-subscription']), + defaultApprovalPolicy: 'auto' + }) + const deps = (runtime as unknown as { + deps: { + loadTurnContext(threadId: string, turnId: string): Promise<{ + bridgeableTools: Array<{ + name: string + providerId?: string + providerKind?: string + }> + contextInstructions?: string[] + } | null> + executeKunTool( + threadId: string, + turnId: string, + toolName: string, + args: Record + ): Promise<{ output: unknown; isError?: boolean }> + } + }).deps + + const context = await deps.loadTurnContext('th', 'tn') + expect(context?.bridgeableTools).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'mcp_docs_lookup', + providerId: 'mcp:docs', + providerKind: 'mcp' + }), + expect.objectContaining({ + name: 'extension_render', + providerId: 'extension:demo', + providerKind: 'extension' + }) + ])) + expect(context?.contextInstructions?.join('\n')).toContain( + 'Kun-managed capabilities are available through the mcp__kun__ tools.' + ) + await expect(deps.executeKunTool('th', 'tn', 'extension_render', {})).resolves.toEqual({ + output: 'extension result', + isError: false + }) + expect(extensionExecute).toHaveBeenCalledOnce() + }) + test('applies a child capability boundary to SDK discovery and execution', async () => { const executionContexts: Array<{ allowedToolNames?: readonly string[] @@ -238,7 +636,7 @@ describe('createAgentSdkRuntime turn context', () => { ids: { next: (prefix) => prefix }, prefix: { systemPrompt: '' }, providerConfigs: { - 'claude-subscription': { kind: 'agent-sdk', apiKey: 'tok' } + 'claude-subscription': { kind: 'agent-sdk', apiKey: 'sk-ant-oat01-tok' } } as never, agentSdkProviderIds: new Set(['claude-subscription']), defaultApprovalPolicy: 'auto', @@ -362,7 +760,9 @@ describe('createAgentSdkRuntime turn context', () => { events: {} as never, ids: { next: (prefix) => prefix }, prefix: { systemPrompt: '' }, - providerConfigs: { 'claude-subscription': { kind: 'agent-sdk', apiKey: 'tok' } } as never, + providerConfigs: { + 'claude-subscription': { kind: 'agent-sdk', apiKey: 'sk-ant-oat01-tok' } + } as never, agentSdkProviderIds: new Set(['claude-subscription']), defaultApprovalPolicy: 'auto' }) @@ -1144,7 +1544,9 @@ describe('createAgentSdkRuntime turn context', () => { events: {} as never, ids: { next: (p: string) => p }, prefix: { systemPrompt: '' }, - providerConfigs: { 'claude-subscription': { kind: 'agent-sdk', apiKey: 'tok' } } as never, + providerConfigs: { + 'claude-subscription': { kind: 'agent-sdk', apiKey: 'sk-ant-oat01-tok' } + } as never, agentSdkProviderIds: new Set(['claude-subscription']), defaultApprovalPolicy: 'auto', instructionRuntime: new InstructionRuntime( diff --git a/kun/src/runtime/agent-sdk/agent-sdk-runtime-factory.ts b/kun/src/runtime/agent-sdk/agent-sdk-runtime-factory.ts index 3e697d66f..685542804 100644 --- a/kun/src/runtime/agent-sdk/agent-sdk-runtime-factory.ts +++ b/kun/src/runtime/agent-sdk/agent-sdk-runtime-factory.ts @@ -3,12 +3,26 @@ * This is the only place that touches the SDK package and kun's concrete stores, * keeping the orchestration (and its tests) free of both. */ -import { AgentSdkRuntime, type SdkRuntimeDeps, type SdkTurnContext } from './agent-sdk-runtime.js' +import { + AgentSdkRuntime, + agentSdkCapabilities, + type SdkRuntimeDeps, + type SdkTurnContext +} from './agent-sdk-runtime.js' import type { SdkStreamResourceLimits } from './sdk-event-mapper.js' -import { resolveSdkModel, type ToolApprovalDecision } from './sdk-options-builder.js' -import type { BridgeableTool, KunToolResult } from './sdk-tool-bridge.js' +import { + normalizeClaudeOAuthToken, + resolveSdkModel, + type ToolApprovalDecision +} from './sdk-options-builder.js' +import { + selectBridgeableTools, + type BridgeableTool, + type KunToolResult +} from './sdk-tool-bridge.js' import type { SdkApi } from './sdk-protocol.js' import type { RuntimeEventRecorder } from '../../services/runtime-event-recorder.js' +import type { LlmDebugSink } from '../../services/llm-debug-recorder.js' import type { TurnService } from '../../services/turn-service.js' import type { SessionStore } from '../../ports/session-store.js' import type { ThreadStore } from '../../ports/thread-store.js' @@ -55,6 +69,20 @@ import { import { shellSpawnEnv } from '../../adapters/tool/builtin-tool-utils.js' import type { TurnLimitsConfig } from '../../loop/turn-limits.js' import { userMessageTextWithComposerContexts } from '../../domain/composer-context.js' +import { mkdir } from 'node:fs/promises' +import { + delegatedCapabilityFingerprint, + delegatedCredentialIdentity, + priorItemsForDelegatedTurn, + type DelegatedSessionCoordinator, + type DelegatedSessionPreparation +} from '../delegated-session-binding.js' + +const CLAUDE_KUN_TOOL_INSTRUCTION = [ + 'Kun-managed capabilities are available through the mcp__kun__ tools.', + 'Use these tools for Kun capabilities such as MCP, extensions, skills, memory, media, GUI input, and delegation.', + 'Their execution remains governed by Kun ToolHost approval and sandbox policy.' +].join(' ') export interface AgentSdkRuntimeFactoryDeps { registry: CapabilityRegistry @@ -68,6 +96,8 @@ export interface AgentSdkRuntimeFactoryDeps { sessionStore: SessionStore threadStore: ThreadStore events: RuntimeEventRecorder + /** Existing Agent Perspective model-request trace sink. */ + debugSink?: LlmDebugSink ids: { next(prefix: string): string } prefix: { systemPrompt: string } /** serve.providers map; `kind:'agent-sdk'` entries carry the OAuth token in apiKey. */ @@ -117,10 +147,15 @@ export interface AgentSdkRuntimeFactoryDeps { /** Optional SDK stream-budget overrides; omitted in normal production wiring. */ sdkStreamLimits?: Partial pathToClaudeCodeExecutable?: string + /** Shared durable provider-session coordinator. */ + sessionCoordinator?: DelegatedSessionCoordinator + contextProfile?: (model: string) => { + contextWindowTokens: number + softThresholdTokens: number + hardThresholdTokens: number + } } -const MAX_DIAGNOSTIC_SESSION_IDS = 256 - /** Lazily load the real SDK without a static import (so kun typechecks without it). */ let sdkPromise: Promise | undefined function loadAgentSdk(): Promise { @@ -186,11 +221,8 @@ function intersectAllowedToolNames( } export function createAgentSdkRuntime(deps: AgentSdkRuntimeFactoryDeps): AgentSdkRuntime { - // Last SDK session id per thread, recorded for diagnostics only. We do NOT - // resume from it: kun owns the canonical history and replays it as a transcript - // every turn (see loadTurnContext), which — unlike the SDK's in-memory resume — - // survives a provider switch mid-thread and a runtime restart. - const sessionIds = new Map() + const sessionIdsByTurn = new Map() + const sessionPreparationsByTurn = new Map() // Skill activation is turn-scoped. Keep the exact result used for the SDK // tool catalog so bridged execution sees the same skill-gated tools after a // GUI input pause/resume. @@ -512,7 +544,11 @@ export function createAgentSdkRuntime(deps: AgentSdkRuntimeFactoryDeps): AgentSd const providerId = turn?.providerId?.trim() || thread.providerId?.trim() const providerCfg = providerId ? deps.providerConfigs[providerId] : undefined - const token = providerCfg?.apiKey?.trim() || deps.defaultToken?.trim() + // An explicit Claude provider owns its credential boundary. Empty means + // ambient Claude Code login; it must never inherit another provider's key. + const token = normalizeClaudeOAuthToken( + providerId ? providerCfg?.apiKey : deps.defaultToken + ) // Resolve skills before listing bridgeable tools. Some managed tools // (notably PPT Master) are deliberately advertised only for an active // skill, and the SDK must see the same per-turn catalog as the native @@ -573,16 +609,22 @@ export function createAgentSdkRuntime(deps: AgentSdkRuntimeFactoryDeps): AgentSd sandboxMode: thread.sandboxMode ?? deps.defaultSandboxMode, awaitUserInput: makeAwaitUserInput(threadId, turnId, new AbortController().signal) }) + if (deps.toolHost) { + // Activate turn-scoped extension contributions before taking the + // canonical registry snapshot used by the SDK MCP bridge. + await deps.toolHost.listTools(bridgeListingContext) + } const bridgeableTools: BridgeableTool[] = deps.registry.listTools(bridgeListingContext).map((spec) => ({ name: spec.name, description: spec.description, - inputSchema: spec.inputSchema + inputSchema: spec.inputSchema, + providerId: spec.providerId, + providerKind: spec.providerKind })) + const bridgedTools = selectBridgeableTools(bridgeableTools) - // The SDK doesn't see kun's history or per-turn context, so assemble both - // here (parity with the native loop's `contextInstructions`). kun owns the - // canonical history, so we replay it as a transcript every turn rather than - // relying on the SDK's in-memory resume (lost on provider switch / restart). + // This is the portable rebase handoff. Compatible consecutive turns use + // the official SDK resume id and do not send this transcript again. const historyTranscript = buildHistoryTranscript( items, turnId, @@ -629,15 +671,65 @@ export function createAgentSdkRuntime(deps: AgentSdkRuntimeFactoryDeps): AgentSd ...(todoInstruction ? [todoInstruction] : []), ...memoryBlocks, ...(skillResolution?.catalogInstruction ? [skillResolution.catalogInstruction] : []), - ...(skillResolution?.instructions ?? []) + ...(skillResolution?.instructions ?? []), + ...(bridgedTools.length ? [CLAUDE_KUN_TOOL_INSTRUCTION] : []) ] + const model = resolveSdkModel(turn?.model || thread.model, deps.defaultModel) + const approvalPolicy = thread.approvalPolicy ?? deps.defaultApprovalPolicy + const sandboxMode = thread.sandboxMode ?? deps.defaultSandboxMode + let preparation: DelegatedSessionPreparation | undefined + let claudeConfigDir: string | undefined + if (deps.sessionCoordinator) { + preparation = await deps.sessionCoordinator.prepare({ + threadId, + route: { + providerKind: 'agent-sdk', + providerId: providerId || 'default', + credentialIdentity: delegatedCredentialIdentity({ + providerId: providerId || 'default', + accountId: turn.accountId || thread.accountId, + credentialSourceId: providerCfg?.credentialSourceId, + credentialSecret: token + }), + workspace: thread.workspace, + model: model ?? 'claude-default', + capabilityFingerprint: delegatedCapabilityFingerprint({ + systemPrompt: deps.prefix.systemPrompt, + threadPersona: thread.systemPrompt?.trim() || '', + approvalPolicy, + sandboxMode, + planMode, + allowSdkBuiltins: + turn?.guiDesignArtifact?.kind === 'svg' + ? false + : deps.allowSdkBuiltins ?? true, + capabilities: agentSdkCapabilities(), + tools: bridgedTools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + providerId: tool.providerId, + providerKind: tool.providerKind + })) + }), + continuationMode: 'native' + }, + priorItems: priorItemsForDelegatedTurn(items, turnId) + }) + if (token) { + claudeConfigDir = deps.sessionCoordinator.store.providerStateDir('agent-sdk', threadId) + await mkdir(claudeConfigDir, { recursive: true, mode: 0o700 }) + } + sessionPreparationsByTurn.set(skillTurnKey(threadId, turnId), preparation) + } + return { workspace: thread.workspace, userText: modelUserText, threadPersona: thread.systemPrompt?.trim() || undefined, - approvalPolicy: thread.approvalPolicy ?? deps.defaultApprovalPolicy, - sandboxMode: thread.sandboxMode, + approvalPolicy, + sandboxMode, planMode, allowSdkBuiltins: turn?.guiDesignArtifact?.kind === 'svg' @@ -647,12 +739,21 @@ export function createAgentSdkRuntime(deps: AgentSdkRuntimeFactoryDeps): AgentSd // Claude Code only accepts Anthropic models; coerce a thread's non-Claude // model (e.g. an old deepseek thread now routed to the subscription) to // the runtime default so the turn doesn't fail "model may not exist". - model: resolveSdkModel(turn?.model || thread.model, deps.defaultModel), + model, + ...(preparation?.nativeSessionId + ? { resumeSessionId: preparation.nativeSessionId } + : {}), + ...(claudeConfigDir ? { claudeConfigDir } : {}), + ...(preparation ? { sessionPreparation: preparation } : {}), + ...(deps.contextProfile + ? { contextProfile: deps.contextProfile(model ?? 'claude-default') } + : {}), oauthToken: token || undefined, ...(images.length ? { images } : {}), bridgeableTools, ...(historyTranscript ? { historyTranscript } : {}), - ...(contextInstructions.length ? { contextInstructions } : {}) + ...(contextInstructions.length ? { contextInstructions } : {}), + ...(activeSkillIds.length ? { activeSkillIds: [...activeSkillIds] } : {}) } }, @@ -761,24 +862,49 @@ export function createAgentSdkRuntime(deps: AgentSdkRuntimeFactoryDeps): AgentSd }, async finishTurn(threadId, turnId, status, error): Promise { + const key = skillTurnKey(threadId, turnId) try { await deps.turns.finishTurn({ threadId, turnId, status, ...(error ? { error } : {}) }) + if (status === 'completed' && deps.sessionCoordinator) { + const preparation = sessionPreparationsByTurn.get(key) + if (preparation) { + try { + await deps.sessionCoordinator.commit({ + preparation, + committedItems: await deps.sessionStore.loadItems(threadId), + lastCommittedTurnId: turnId, + nativeSessionId: sessionIdsByTurn.get(key) + }) + } catch { + // Native continuation is an optimization. A failed checkpoint + // commit must not turn a successfully persisted Kun turn into a + // failed answer; the next history digest safely forces a rebase. + } + } + } } finally { - activeSkillIdsByTurn.delete(skillTurnKey(threadId, turnId)) - skillPromptByTurn.delete(skillTurnKey(threadId, turnId)) + activeSkillIdsByTurn.delete(key) + skillPromptByTurn.delete(key) + sessionIdsByTurn.delete(key) + sessionPreparationsByTurn.delete(key) if (typeof deps.skillRuntime?.clearTurnActivation === 'function') { deps.skillRuntime.clearTurnActivation(threadId, turnId) } } }, - async saveSessionId(threadId, sessionId): Promise { - sessionIds.delete(threadId) - sessionIds.set(threadId, sessionId) - if (sessionIds.size > MAX_DIAGNOSTIC_SESSION_IDS) { - const oldest = sessionIds.keys().next().value - if (oldest !== undefined) sessionIds.delete(oldest) - } + async saveSessionId(threadId, turnId, sessionId): Promise { + sessionIdsByTurn.set(skillTurnKey(threadId, turnId), sessionId) + }, + + async rejectResume(threadId, turnId): Promise { + const key = skillTurnKey(threadId, turnId) + const preparation = sessionPreparationsByTurn.get(key) + if (!preparation) return + sessionPreparationsByTurn.set( + key, + await deps.sessionCoordinator!.rejectResume(preparation) + ) }, loadSdk: loadAgentSdk, @@ -789,11 +915,16 @@ export function createAgentSdkRuntime(deps: AgentSdkRuntimeFactoryDeps): AgentSd kunSystemPrompt: () => deps.prefix.systemPrompt, nextId: (prefix) => deps.ids.next(prefix), getTurnLimits: () => deps.turnLimits, + ...(deps.debugSink ? { debugSink: deps.debugSink } : {}), ...(deps.sdkStreamLimits ? { getSdkStreamLimits: () => deps.sdkStreamLimits } : {}), ...(deps.pathToClaudeCodeExecutable ? { pathToClaudeCodeExecutable: deps.pathToClaudeCodeExecutable } + : {}), + ...(deps.sessionCoordinator + ? { runExclusive: (threadId, operation) => + deps.sessionCoordinator!.runExclusive(threadId, operation) } : {}) } diff --git a/kun/src/runtime/agent-sdk/agent-sdk-runtime.test.ts b/kun/src/runtime/agent-sdk/agent-sdk-runtime.test.ts index 11a13dae1..cc97d7ba8 100644 --- a/kun/src/runtime/agent-sdk/agent-sdk-runtime.test.ts +++ b/kun/src/runtime/agent-sdk/agent-sdk-runtime.test.ts @@ -10,6 +10,7 @@ import { } from './agent-sdk-runtime.js' import type { SdkApi, SdkCanUseTool, SdkMessage, SdkQueryResult } from './sdk-protocol.js' import type { RuntimeEventDraft } from '../../services/runtime-event-recorder.js' +import { LlmDebugRecorder } from '../../services/llm-debug-recorder.js' import type { TurnItem } from '../../contracts/items.js' function fakeSdk(messages: SdkMessage[], onQuery?: (opts: unknown) => void): SdkApi { @@ -170,7 +171,7 @@ function makeDeps(overrides: Partial = {}): { finishTurn: async (_t, _u, status, error) => { finished.push({ status, error }) }, - saveSessionId: async (_t, id) => { + saveSessionId: async (_t, _turnId, id) => { sessions.push(id) }, loadSdk: async () => fakeSdk([]), @@ -306,6 +307,234 @@ describe('AgentSdkRuntime.runTurn', () => { expect(persistedKinds).toContain('assistant_text') }) + test('publishes a sanitized Claude SDK trace to Agent Perspective', async () => { + const debugSink = new LlmDebugRecorder() + const { deps } = makeDeps({ + debugSink, + loadTurnContext: async () => ({ + workspace: '/ws', + userText: 'inspect this turn', + approvalPolicy: 'auto', + oauthToken: 'sk-ant-oat01-claude-oauth-secret', + images: [{ mediaType: 'image/png', base64: 'private-image-bytes' }], + contextInstructions: ['Workspace AGENTS.md instruction'], + bridgeableTools: [{ + name: 'generate_image', + description: 'Generate an image', + inputSchema: { type: 'object' }, + providerId: 'image:primary', + providerKind: 'image' + }] + }), + loadSdk: async () => fakeSdk(STREAM) + }) + + await expect(new AgentSdkRuntime(deps).runTurn( + 'th', + 'tn', + new AbortController().signal + )).resolves.toBe('completed') + + const trace = debugSink.snapshot()[0]?.exchanges[0] + expect(trace).toMatchObject({ + transport: 'sdk', + endpointFormat: 'agent-sdk', + status: 'completed', + delegated: { + providerKind: 'agent-sdk', + phase: 'rebased', + contextManagement: 'sdk-managed', + nativeHistory: 'none' + }, + request: { + method: 'SDK', + url: 'agent-sdk://local/query' + }, + toolCatalog: [{ + name: 'mcp__kun__generate_image', + providerId: 'image:primary', + providerKind: 'image' + }], + decoded: { + text: 'Hi there', + toolCalls: [{ + callId: 'toolu_1', + toolName: 'mcp__kun__generate_image' + }], + toolResults: [{ + callId: 'toolu_1', + toolName: 'mcp__kun__generate_image', + output: 'done', + isError: false + }] + } + }) + const serialized = JSON.stringify(trace) + expect(serialized).not.toContain('claude-oauth-secret') + expect(serialized).not.toContain('private-image-bytes') + expect(serialized).not.toContain('sess_42') + expect(JSON.parse(trace!.request.body.text)).toMatchObject({ + system: 'You are kun.', + instructions: ['Workspace AGENTS.md instruction'], + tools: [{ + name: 'mcp__kun__generate_image', + description: 'Generate an image', + input_schema: { type: 'object' } + }], + attachments: { + count: 1, + images: [{ mediaType: 'image/png' }] + } + }) + }) + + test('uses official resume without replaying portable history', async () => { + const queries: Array<{ prompt: unknown; options?: unknown }> = [] + const { deps } = makeDeps({ + loadTurnContext: async () => ({ + workspace: '/ws', + userText: 'current request', + approvalPolicy: 'auto', + bridgeableTools: [], + resumeSessionId: 'session_previous', + historyTranscript: '[user] should not be replayed' + }), + loadSdk: async () => fakeSdkAttempts([STREAM], (input) => queries.push(input)) + }) + + await expect(new AgentSdkRuntime(deps).runTurn( + 'th', + 'tn', + new AbortController().signal + )).resolves.toBe('completed') + + expect(queries).toHaveLength(1) + expect(queries[0]?.options).toMatchObject({ resume: 'session_previous' }) + expect(String(queries[0]?.prompt)).toContain('current request') + expect(String(queries[0]?.prompt)).not.toContain('should not be replayed') + }) + + test('retains the validated resume id when a successful stream omits init metadata', async () => { + const { deps, sessions } = makeDeps({ + loadTurnContext: async () => ({ + workspace: '/ws', + userText: 'current request', + approvalPolicy: 'auto', + bridgeableTools: [], + resumeSessionId: 'session_previous' + }), + loadSdk: async () => fakeSdk(svgSdkTextAttempt('continued')) + }) + + await expect(new AgentSdkRuntime(deps).runTurn( + 'th', + 'tn', + new AbortController().signal + )).resolves.toBe('completed') + + expect(sessions).toEqual(['session_previous']) + }) + + test('rebases once from portable history when native resume cannot load', async () => { + const queries: Array<{ prompt: unknown; options?: unknown }> = [] + const rejectResume = vi.fn() + let call = 0 + const sdk = fakeSdkAttempts([STREAM], (input) => queries.push(input)) + const successfulQuery = sdk.query + sdk.query = (input): SdkQueryResult => { + queries.push(input as { prompt: unknown; options?: unknown }) + call += 1 + if (call === 1) { + const failed = (async function* (): AsyncGenerator { + yield await Promise.reject(new Error('session checkpoint missing')) + })() as SdkQueryResult + failed.interrupt = async () => {} + return failed + } + return successfulQuery(input) + } + const debugSink = new LlmDebugRecorder() + const { deps } = makeDeps({ + loadTurnContext: async () => ({ + workspace: '/ws', + userText: 'current request', + approvalPolicy: 'auto', + bridgeableTools: [], + resumeSessionId: 'session_missing', + historyTranscript: '[user] portable recovery state' + }), + loadSdk: async () => sdk, + rejectResume, + debugSink + }) + + await expect(new AgentSdkRuntime(deps).runTurn( + 'th', + 'tn', + new AbortController().signal + )).resolves.toBe('completed') + + expect(rejectResume).toHaveBeenCalledWith('th', 'tn') + const actualQueries = queries.filter((entry, index) => index === 0 || index === queries.length - 1) + expect(actualQueries[0]?.options).toMatchObject({ resume: 'session_missing' }) + expect(actualQueries.at(-1)?.options).not.toHaveProperty('resume') + expect(String(actualQueries.at(-1)?.prompt)).toContain('portable recovery state') + const traces = debugSink.snapshot() + .flatMap((round) => round.exchanges) + .sort((left, right) => left.sequence - right.sequence) + expect(traces).toHaveLength(2) + expect(traces[0]).toMatchObject({ + status: 'transport_error', + delegated: { + providerKind: 'agent-sdk', + phase: 'resumed', + nativeHistory: 'unknown' + } + }) + expect(traces[1]).toMatchObject({ + status: 'completed', + delegated: { + providerKind: 'agent-sdk', + phase: 'rebased', + reason: 'native_state_unavailable', + nativeHistory: 'none' + } + }) + expect(JSON.stringify(traces)).not.toContain('session_missing') + }) + + test('rebases when the official resume query throws synchronously', async () => { + let queryCount = 0 + const sdk = fakeSdk(STREAM) + const query = sdk.query + sdk.query = (input): SdkQueryResult => { + queryCount += 1 + if (queryCount === 1) throw new Error('native session unavailable') + return query(input) + } + const rejectResume = vi.fn() + const { deps } = makeDeps({ + loadTurnContext: async () => ({ + workspace: '/ws', + userText: 'current request', + approvalPolicy: 'auto', + bridgeableTools: [], + resumeSessionId: 'session_missing', + historyTranscript: '[user] portable recovery state' + }), + loadSdk: async () => sdk, + rejectResume + }) + + await expect(new AgentSdkRuntime(deps).runTurn( + 'th', + 'tn', + new AbortController().signal + )).resolves.toBe('completed') + expect(queryCount).toBe(2) + expect(rejectResume).toHaveBeenCalledWith('th', 'tn') + }) + test('coalesces token-granular SDK deltas before durable recording', async () => { const text = 'x'.repeat(1_000) const messages: SdkMessage[] = [ @@ -401,7 +630,7 @@ describe('AgentSdkRuntime.runTurn', () => { const running = new AgentSdkRuntime(deps).runTurn('th', 'tn', new AbortController().signal) await waiting - expect(events).toHaveLength(0) + expect(events.filter((event) => event.kind === 'assistant_text_delta')).toHaveLength(0) await vi.advanceTimersByTimeAsync(40) expect(events).toContainEqual(expect.objectContaining({ kind: 'assistant_text_delta', item: expect.objectContaining({ text: 'live' }) @@ -433,9 +662,12 @@ describe('AgentSdkRuntime.runTurn', () => { await expect(new AgentSdkRuntime(deps).runTurn( 'th', 'tn', new AbortController().signal )).resolves.toBe('failed') - expect(events.map((event) => event.kind)).toEqual(['assistant_text_delta', 'error']) - expect((events[0] as { item: { text: string } }).item.text).toBe('ok') - expect(events[1]).toMatchObject({ code: 'stream_resource_limit' }) + const terminalEvents = events.filter((event) => + event.kind === 'assistant_text_delta' || event.kind === 'error' + ) + expect(terminalEvents.map((event) => event.kind)).toEqual(['assistant_text_delta', 'error']) + expect((terminalEvents[0] as { item: { text: string } }).item.text).toBe('ok') + expect(terminalEvents[1]).toMatchObject({ code: 'stream_resource_limit' }) }) test('flushes pending SDK deltas when the user aborts a stalled stream', async () => { @@ -493,7 +725,21 @@ describe('AgentSdkRuntime.runTurn', () => { expect(seenOptions.env?.CLAUDE_CODE_OAUTH_TOKEN).toBe('sk-ant-oat01-tok') }) - test('maps native maxSteps onto the SDK maxTurns option', async () => { + test('omits the SDK maxTurns option by default', async () => { + let seenMaxTurns: number | undefined + const { deps } = makeDeps({ + loadSdk: async () => fakeSdk(STREAM, (options) => { + seenMaxTurns = (options as { maxTurns?: number }).maxTurns + }) + }) + + await expect(new AgentSdkRuntime(deps).runTurn( + 'th', 'tn', new AbortController().signal + )).resolves.toBe('completed') + expect(seenMaxTurns).toBeUndefined() + }) + + test('maps an explicit native maxSteps onto the SDK maxTurns option', async () => { let seenMaxTurns: number | undefined const { deps } = makeDeps({ getTurnLimits: () => ({ maxSteps: 7, maxWallTimeMs: 60_000, maxToolCallsPerStep: 3 }), @@ -937,7 +1183,9 @@ describe('AgentSdkRuntime.runTurn', () => { }) test('maps SDK error_max_turns onto the native turn_step_limit code', async () => { + const debugSink = new LlmDebugRecorder() const { deps, events, finished } = makeDeps({ + debugSink, getTurnLimits: () => ({ maxSteps: 3 }), loadSdk: async () => fakeSdk([{ type: 'result', subtype: 'error_max_turns', is_error: true, num_turns: 3 @@ -951,6 +1199,13 @@ describe('AgentSdkRuntime.runTurn', () => { kind: 'error', code: 'turn_step_limit', severity: 'warning' })) expect(finished.at(-1)?.error).toBe('turn exceeded 3 model steps') + expect(debugSink.snapshot()[0]?.exchanges[0]).toMatchObject({ + status: 'completed', + decoded: { + error: 'error_max_turns', + stopReason: 'error' + } + }) }) test('fails closed when SDK usage reports more turns than the supplied maxTurns', async () => { @@ -985,6 +1240,77 @@ describe('AgentSdkRuntime.runTurn', () => { expect(finished[0]).toMatchObject({ status: 'failed' }) }) + test('redacts a Claude credential from Agent Perspective and conversation failures', async () => { + const token = 'sk-ant-oat01-private-auth-token' + const debugSink = new LlmDebugRecorder() + const { deps, events, finished } = makeDeps({ + debugSink, + loadTurnContext: async () => ({ + workspace: '/ws', + userText: 'authenticate', + approvalPolicy: 'auto', + oauthToken: token, + bridgeableTools: [] + }), + loadSdk: async () => ({ + query: () => { + throw new Error(`Failed to authenticate: 401 Invalid Bearer ${token}`) + }, + createSdkMcpServer: () => ({ type: 'sdk', name: 'kun', instance: {} }), + tool: () => ({}) + }) + }) + + await expect(new AgentSdkRuntime(deps).runTurn( + 'th', + 'tn', + new AbortController().signal + )).resolves.toBe('failed') + + const diagnostics = JSON.stringify({ + events, + finished, + perspective: debugSink.snapshot() + }) + expect(diagnostics).toContain('401 Invalid Bearer [REDACTED]') + expect(diagnostics).not.toContain(token) + }) + + test('redacts credentials from a terminal SDK error result', async () => { + const token = 'sk-ant-oat01-terminal-result-secret' + const debugSink = new LlmDebugRecorder() + const { deps, finished } = makeDeps({ + debugSink, + loadTurnContext: async () => ({ + workspace: '/ws', + userText: 'authenticate', + approvalPolicy: 'auto', + oauthToken: token, + bridgeableTools: [] + }), + loadSdk: async () => fakeSdk([{ + type: 'result', + subtype: 'error_during_execution', + is_error: true, + result: `Invalid Bearer ${token}`, + num_turns: 1 + } as SdkMessage]) + }) + + await expect(new AgentSdkRuntime(deps).runTurn( + 'th', + 'tn', + new AbortController().signal + )).resolves.toBe('failed') + + const diagnostics = JSON.stringify({ + finished, + perspective: debugSink.snapshot() + }) + expect(diagnostics).toContain('Invalid Bearer [REDACTED]') + expect(diagnostics).not.toContain(token) + }) + test('forwards image attachments as a structured user message (text + image block)', async () => { let prompt: unknown const sdk = fakeSdk(STREAM) diff --git a/kun/src/runtime/agent-sdk/agent-sdk-runtime.ts b/kun/src/runtime/agent-sdk/agent-sdk-runtime.ts index c8913eabb..c4d2c56d7 100644 --- a/kun/src/runtime/agent-sdk/agent-sdk-runtime.ts +++ b/kun/src/runtime/agent-sdk/agent-sdk-runtime.ts @@ -13,7 +13,15 @@ import { existsSync, realpathSync } from 'node:fs' import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import type { RuntimeEventDraft } from '../../services/runtime-event-recorder.js' import type { TurnItem } from '../../contracts/items.js' +import type { + ModelRequestTraceDelegated, + ModelRequestTraceRecord +} from '../../contracts/model-request-trace.js' import type { ApprovalPolicy, SandboxMode } from '../../contracts/policy.js' +import type { + LlmDebugRound, + LlmDebugSink +} from '../../services/llm-debug-recorder.js' import { makeAssistantReasoningItem, makeAssistantTextItem } from '../../domain/item.js' import { normalizeTurnLimits, type TurnLimitsConfig } from '../../loop/turn-limits.js' import { utf8PrefixWithinBytes } from '../../shared/utf8-text-blocks.js' @@ -28,6 +36,7 @@ import { type ToolApprovalDecision } from './sdk-options-builder.js' import { + bridgedToolModelName, bridgedToolModelNames, buildBridgedToolSpecs, selectBridgeableTools, @@ -37,6 +46,8 @@ import { } from './sdk-tool-bridge.js' import { composeSdkPromptText } from './sdk-context-assembler.js' import type { SdkApi, SdkMessage, SdkQueryResult } from './sdk-protocol.js' +import type { DelegatedSessionPreparation } from '../delegated-session-binding.js' +import type { DelegatedRuntimeCapabilities } from '../delegated-turn-runtime.js' export type TurnStatus = 'completed' | 'failed' | 'aborted' @@ -66,6 +77,15 @@ export interface SdkTurnContext { model?: string /** Prior SDK session id for multi-turn continuity. */ resumeSessionId?: string + /** Kun-owned local Claude state root for this thread. */ + claudeConfigDir?: string + /** Opaque, non-secret coordinator token for committing a successful turn. */ + sessionPreparation?: DelegatedSessionPreparation + contextProfile?: { + contextWindowTokens: number + softThresholdTokens: number + hardThresholdTokens: number + } /** Subscription OAuth token; absent => rely on the host's Claude Code login. */ oauthToken?: string /** Image attachments to forward to the model (base64 + media type). */ @@ -73,8 +93,8 @@ export interface SdkTurnContext { /** kun tool catalog to consider bridging (overlap/excluded are filtered here). */ bridgeableTools: BridgeableTool[] /** - * Prior-conversation transcript replayed each turn so the model has kun's - * canonical history (the SDK doesn't see it otherwise). '' / absent => none. + * Portable prior-conversation handoff used only when creating/rebasing a + * native session. Resumed turns send only their current delta. */ historyTranscript?: string /** @@ -83,6 +103,7 @@ export interface SdkTurnContext { * Mirrors the native loop's `contextInstructions`. */ contextInstructions?: string[] + activeSkillIds?: string[] } /** @@ -138,8 +159,10 @@ export interface SdkRuntimeDeps { applyItem(threadId: string, item: TurnItem): Promise /** Finish the turn lifecycle (turns.finishTurn). */ finishTurn(threadId: string, turnId: string, status: TurnStatus, error?: string): Promise - /** Persist the SDK session id on the thread for next-turn resume. */ - saveSessionId(threadId: string, sessionId: string): Promise + /** Stage the SDK session id for commit after Kun finishes successfully. */ + saveSessionId(threadId: string, turnId: string, sessionId: string): Promise + /** Rotate an unusable native resume preparation before the portable retry. */ + rejectResume?(threadId: string, turnId: string): Promise | void /** Lazy-load the real `@anthropic-ai/claude-agent-sdk`. */ loadSdk(): Promise /** Base process env to scope for the Claude Code subprocess. */ @@ -152,8 +175,12 @@ export interface SdkRuntimeDeps { getTurnLimits?(): TurnLimitsConfig | undefined /** Optional SDK stream-budget overrides (primarily a focused-test seam). */ getSdkStreamLimits?(): Partial | undefined + /** Existing Agent Perspective trace sink; observability must never affect execution. */ + debugSink?: LlmDebugSink /** Optional explicit path to the bundled Claude Code binary (packaging). */ pathToClaudeCodeExecutable?: string + /** Serialize native ownership for one Kun thread. */ + runExclusive?(threadId: string, operation: () => Promise): Promise } /** Persist an item only at milestones, not on every streaming delta. */ @@ -269,10 +296,26 @@ export class AgentSdkRuntime { return this.deps.handlesProvider(providerId) } + capabilities(providerId: string | undefined): DelegatedRuntimeCapabilities | undefined { + if (!this.handlesProvider(providerId)) return undefined + return agentSdkCapabilities() + } + async runTurn( threadId: string, turnId: string, signal: AbortSignal + ): Promise<'completed' | 'failed' | 'aborted'> { + const execute = () => this.runTurnOwned(threadId, turnId, signal) + return this.deps.runExclusive + ? this.deps.runExclusive(threadId, execute) + : execute() + } + + private async runTurnOwned( + threadId: string, + turnId: string, + signal: AbortSignal ): Promise<'completed' | 'failed' | 'aborted'> { const ctx = await this.deps.loadTurnContext(threadId, turnId) if (!ctx) { @@ -375,10 +418,13 @@ export class AgentSdkRuntime { const sdk = await awaitAbortable(() => this.deps.loadSdk(), abort.signal) // Bridge kun-exclusive tools into an in-process MCP server. - const bridged = buildBridgedToolSpecs(selectBridgeableTools(ctx.bridgeableTools), (name, args) => + const selectedKunTools = selectBridgeableTools(ctx.bridgeableTools) + const bridged = buildBridgedToolSpecs(selectedKunTools, (name, args) => this.deps.executeKunTool(threadId, turnId, name, args, abort.signal) ) - const buildOptions = (maxTurns: number) => assembleSdkOptions({ + let resumeSessionId = ctx.resumeSessionId + let activeRebaseReason = ctx.sessionPreparation?.rebaseReason + const buildOptions = (maxTurns?: number) => assembleSdkOptions({ cwd: ctx.workspace, kunSystemPrompt: this.deps.kunSystemPrompt(), threadPersona: ctx.threadPersona, @@ -402,26 +448,72 @@ export class AgentSdkRuntime { if (sandboxDecision) return sandboxDecision return this.deps.decideToolApproval(threadId, turnId, name, input, abort.signal) }), - baseEnv: this.deps.baseEnv(), + baseEnv: { + ...this.deps.baseEnv(), + ...(ctx.claudeConfigDir ? { CLAUDE_CONFIG_DIR: ctx.claudeConfigDir } : {}) + }, oauthToken: ctx.oauthToken, abortController: abort, - maxTurns, + ...(maxTurns !== undefined ? { maxTurns } : {}), ...(ctx.model ? { model: ctx.model } : {}), - ...(ctx.resumeSessionId ? { resume: ctx.resumeSessionId } : {}), + ...(resumeSessionId ? { resume: resumeSessionId } : {}), ...(this.deps.pathToClaudeCodeExecutable ? { pathToClaudeCodeExecutable: this.deps.pathToClaudeCodeExecutable } : {}) }) - // kun owns canonical history, so each SDK turn is stateless: replay the - // prior conversation + per-turn instructions as text and end with the live - // request. (Deliberately NOT using the SDK's `resume` — it's lost on a - // provider switch or runtime restart; the transcript survives both.) - const composedText = composeSdkPromptText({ - ...(ctx.historyTranscript ? { historyTranscript: ctx.historyTranscript } : {}), + // A compatible native session already owns prior context. Portable + // history is sent only when seeding a new generation. + const composeTurnText = (): string => composeSdkPromptText({ + ...(!resumeSessionId && ctx.historyTranscript + ? { historyTranscript: ctx.historyTranscript } + : {}), userText: ctx.userText, ...(ctx.contextInstructions?.length ? { instructionBlocks: ctx.contextInstructions } : {}) }) + const capabilities = agentSdkCapabilities() + await this.deps.recordEvent({ + kind: 'delegated_runtime', + threadId, + turnId, + providerKind: 'agent-sdk', + providerId: ctx.sessionPreparation?.route.providerId ?? 'default', + phase: resumeSessionId ? 'resumed' : 'rebased', + ...(ctx.sessionPreparation?.rebaseReason + ? { reason: ctx.sessionPreparation.rebaseReason } + : {}), + capabilities + }) + const recordContextSnapshot = async (): Promise => { + if (!ctx.contextProfile) return + const system = estimatedTokens([ + this.deps.kunSystemPrompt(), + ctx.threadPersona ?? '' + ].join('\n')) + const tools = estimatedTokens(JSON.stringify(selectedKunTools)) + const skills = estimatedTokens((ctx.contextInstructions ?? []).join('\n')) + const messages = estimatedTokens([ + resumeSessionId ? '' : ctx.historyTranscript ?? '', + ctx.userText + ].join('\n')) + const other = (ctx.images?.length ?? 0) * 1_024 + await this.deps.recordEvent({ + kind: 'context_snapshot', + threadId, + turnId, + model: ctx.model ?? 'claude-default', + providerId: ctx.sessionPreparation?.route.providerId ?? 'default', + stepIndex: 0, + ...ctx.contextProfile, + estimatedInputTokens: tools + system + skills + messages + other, + breakdown: { tools, system, skills, messages, other }, + toolCount: selectedKunTools.length, + activeSkillIds: [...(ctx.activeSkillIds ?? [])], + contextManagement: 'sdk-managed', + nativeHistory: resumeSessionId ? 'unknown' : 'none' + }) + } + await recordContextSnapshot() const svgCompletion: SdkSvgCompletionState = { sequence: 0, lastMutation: -1, @@ -432,11 +524,14 @@ export class AgentSdkRuntime { let stepLimitFailed = false let sdkTurnsUsed = 0 for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - const remainingTurns = limits.maxSteps - sdkTurnsUsed - if (remainingTurns <= 0) { + const remainingTurns = limits.maxSteps === undefined + ? undefined + : limits.maxSteps - sdkTurnsUsed + if (remainingTurns !== undefined && remainingTurns <= 0) { stepLimitFailed = true break } + const composedText = composeTurnText() const attemptText = attempt === 0 ? composedText : `${composedText}\n\n${svgCompletionRecoveryInstruction(svgCompletion)}` @@ -445,68 +540,147 @@ export class AgentSdkRuntime { : attemptText const options = buildOptions(remainingTurns) mapper.beginQuery() - const stream = sdk.query({ prompt, options }) - activeStream = stream - activeStreamInterrupted = false let attemptFinalSeen = false + let attemptMessageSeen = false let attemptTurns = 0 - const iterator = stream[Symbol.asyncIterator]() - for (;;) { - const next = await awaitAbortable(() => iterator.next(), abort.signal) - if (next.done) break - const message = next.value - if (signal.aborted || abort.signal.aborted) { - interruptActiveStream() - break - } - if (message.type === 'result') { - attemptFinalSeen = true - attemptTurns = sdkResultTurnCount(message) + let trace = startAgentSdkTrace(this.deps.debugSink, { + threadId, + turnId, + provider: ctx.sessionPreparation?.route.providerId ?? 'default', + model: ctx.model ?? 'claude-default', + prompt: attemptText, + systemPrompt: this.deps.kunSystemPrompt(), + threadPersona: ctx.threadPersona, + contextInstructions: ctx.contextInstructions ?? [], + tools: selectedKunTools, + images: (ctx.images ?? []).map((image) => ({ mediaType: image.mediaType })), + approvalPolicy: ctx.approvalPolicy, + sandboxMode: ctx.sandboxMode, + oauthToken: ctx.oauthToken, + delegated: { + providerKind: 'agent-sdk', + phase: resumeSessionId ? 'resumed' : 'rebased', + ...(!resumeSessionId && activeRebaseReason + ? { reason: activeRebaseReason } + : {}), + contextManagement: 'sdk-managed', + nativeHistory: resumeSessionId ? 'unknown' : 'none', + capabilities } - for (const draft of mapper.map(message)) { - const delta = assistantDeltaOf(draft) - if (delta) { - await deltaEvents.append(delta) - continue + }) + try { + const stream = sdk.query({ prompt, options }) + activeStream = stream + activeStreamInterrupted = false + const iterator = stream[Symbol.asyncIterator]() + for (;;) { + const next = await awaitAbortable(() => iterator.next(), abort.signal) + if (next.done) break + attemptMessageSeen = true + const message = next.value + if (signal.aborted || abort.signal.aborted) { + interruptActiveStream() + break + } + if (message.type === 'result') { + attemptFinalSeen = true + attemptTurns = sdkResultTurnCount(message) } - // Preserve the mapper's exact event order: milestones, tools, - // usage, and errors may not overtake pending assistant deltas. - await deltaEvents.flush() - const item = itemOf(draft) - if (ctx.requireSvgCompletion && item) observeSvgToolResult(svgCompletion, item) - if (item && shouldPersist(item)) { - // applyItem persists the item AND records its own item_created event, - // so only ALSO record non-item_created signal events (tool_call_ready, - // tool_call_finished) — never the item_created draft itself, or the - // item would be published twice. - await this.deps.applyItem(threadId, item) - if (draft.kind !== 'item_created') await this.deps.recordEvent(draft) - } else { - await this.deps.recordEvent(draft) + for (const draft of mapper.map(message)) { + captureAgentSdkTraceDraft(trace, draft) + const delta = assistantDeltaOf(draft) + if (delta) { + await deltaEvents.append(delta) + continue + } + // Preserve the mapper's exact event order: milestones, tools, + // usage, and errors may not overtake pending assistant deltas. + await deltaEvents.flush() + const item = itemOf(draft) + if (ctx.requireSvgCompletion && item) observeSvgToolResult(svgCompletion, item) + if (item && shouldPersist(item)) { + // applyItem persists the item AND records its own item_created event, + // so only ALSO record non-item_created signal events (tool_call_ready, + // tool_call_finished) — never the item_created draft itself, or the + // item would be published twice. + await this.deps.applyItem(threadId, item) + if (draft.kind !== 'item_created') await this.deps.recordEvent(draft) + } else { + await this.deps.recordEvent(draft) + } } + // `result` is terminal and already carries usage/final status. Give + // the Query a bounded chance to clean up before an SVG retry starts. + if (attemptFinalSeen) { + const closed = await closeIterator(iterator, abort.signal) + if (!closed) interruptActiveStream() + break + } + } + if (!attemptFinalSeen && !signal.aborted && !abort.signal.aborted) { + const protocolError = new AgentSdkProtocolError( + 'agent SDK stream ended without a terminal result' + ) + await finishAgentSdkTrace(trace, { kind: 'error', error: protocolError }) + trace = undefined + throw protocolError } - // `result` is terminal and already carries usage/final status. Give - // the Query a bounded chance to clean up before an SVG retry starts. - if (attemptFinalSeen) { - const closed = await closeIterator(iterator, abort.signal) - if (!closed) interruptActiveStream() - break + const attemptFinal = mapper.getFinal() + if (attemptFinalSeen && attemptFinal?.status === 'failed') { + await finishAgentSdkTrace(trace, { + kind: 'failed', + error: new Error(sanitizeAgentSdkError( + attemptFinal.message ?? 'agent SDK query failed', + ctx.oauthToken + )) + }) + } else if (signal.aborted || abort.signal.aborted) { + await finishAgentSdkTrace(trace, { + kind: 'error', + error: abortError(abort.signal) + }) + } else { + await finishAgentSdkTrace(trace, { kind: 'completed' }) + } + trace = undefined + } catch (error) { + await finishAgentSdkTrace(trace, { + kind: 'error', + error: new Error(sanitizeAgentSdkError(error, ctx.oauthToken)) + }) + trace = undefined + if (resumeSessionId && !attemptMessageSeen && !abort.signal.aborted) { + resumeSessionId = undefined + activeRebaseReason = 'native_state_unavailable' + activeStream = undefined + await this.deps.rejectResume?.(threadId, turnId) + await this.deps.recordEvent({ + kind: 'delegated_runtime', + threadId, + turnId, + providerKind: 'agent-sdk', + providerId: ctx.sessionPreparation?.route.providerId ?? 'default', + phase: 'rebased', + reason: 'native_state_unavailable', + capabilities + }) + await recordContextSnapshot() + attempt -= 1 + continue } + throw error } if (timedOut) interruptActiveStream() activeStream = undefined - if (!attemptFinalSeen && !signal.aborted && !abort.signal.aborted) { - throw new AgentSdkProtocolError('agent SDK stream ended without a terminal result') - } // Starting a query consumes at least one native model step even if a // malformed/aborted SDK stream omits its terminal result message. sdkTurnsUsed += attemptFinalSeen ? Math.max(1, attemptTurns) : 1 - if (sdkTurnsUsed > limits.maxSteps) stepLimitFailed = true + if (limits.maxSteps !== undefined && sdkTurnsUsed > limits.maxSteps) stepLimitFailed = true if (attemptFinalSeen && mapper.getFinal()?.status === 'failed') break if (signal.aborted || abort.signal.aborted || !ctx.requireSvgCompletion || svgCompletionSatisfied(svgCompletion)) { break } - if (sdkTurnsUsed >= limits.maxSteps) { + if (limits.maxSteps !== undefined && sdkTurnsUsed >= limits.maxSteps) { stepLimitFailed = true break } @@ -525,8 +699,11 @@ export class AgentSdkRuntime { } await deltaEvents.flush() - const sessionId = mapper.getSessionId() - if (sessionId) await this.deps.saveSessionId(threadId, sessionId) + // Some SDK versions omit a fresh init/session message when resuming. + // A successful resumed query still advances the already validated native + // session, so retain that ID instead of downgrading the binding. + const sessionId = mapper.getSessionId() ?? resumeSessionId + if (sessionId) await this.deps.saveSessionId(threadId, turnId, sessionId) if (signal.aborted) { await this.deps.finishTurn(threadId, turnId, 'aborted') @@ -536,7 +713,7 @@ export class AgentSdkRuntime { const message = `turn exceeded ${maxWallTimeMs}ms wall time` return await failWithLimit('turn_wall_time_limit', message) } - if (stepLimitFailed) { + if (stepLimitFailed && limits.maxSteps !== undefined) { return await failWithLimit('turn_step_limit', `turn exceeded ${limits.maxSteps} model steps`) } if (completionGateFailed) { @@ -546,12 +723,17 @@ export class AgentSdkRuntime { } const final = mapper.getFinal() - if (final?.code === 'turn_step_limit') { + if (final?.code === 'turn_step_limit' && limits.maxSteps !== undefined) { return await failWithLimit('turn_step_limit', `turn exceeded ${limits.maxSteps} model steps`) } const status: 'completed' | 'failed' | 'aborted' = final?.status === 'failed' ? 'failed' : 'completed' - await this.deps.finishTurn(threadId, turnId, status, final?.message) + await this.deps.finishTurn( + threadId, + turnId, + status, + final?.message ? sanitizeAgentSdkError(final.message, ctx.oauthToken) : undefined + ) return status } catch (err) { let failure = err @@ -588,7 +770,7 @@ export class AgentSdkRuntime { } abort.abort(failure) interruptActiveStream() - const message = failure instanceof Error ? failure.message : String(failure) + const message = sanitizeAgentSdkError(failure, ctx.oauthToken) await this.deps.recordEvent({ kind: 'error', threadId, turnId, message }) await this.deps.finishTurn(threadId, turnId, 'failed', message) return 'failed' @@ -600,6 +782,245 @@ export class AgentSdkRuntime { } } +type AgentSdkTrace = { + sink: LlmDebugSink + round: LlmDebugRound + record: ModelRequestTraceRecord + currentText: string + currentReasoning: string +} + +function startAgentSdkTrace( + sink: LlmDebugSink | undefined, + input: { + threadId: string + turnId: string + provider: string + model: string + prompt: string + systemPrompt: string + threadPersona?: string + contextInstructions: readonly string[] + tools: readonly BridgeableTool[] + images: ReadonlyArray<{ mediaType: string }> + approvalPolicy: ApprovalPolicy + sandboxMode?: SandboxMode + oauthToken?: string + delegated: ModelRequestTraceDelegated + } +): AgentSdkTrace | undefined { + if (!sink?.beginSdkInvocation) return undefined + let round: LlmDebugRound | undefined + try { + round = sink.start({ + threadId: input.threadId, + turnId: input.turnId, + provider: input.provider, + model: input.model, + toolCatalog: input.tools.map((tool) => ({ + name: bridgedToolModelName(tool.name), + ...(tool.providerId ? { providerId: tool.providerId } : {}), + ...(tool.providerKind ? { providerKind: tool.providerKind } : {}) + })) + }) + const record = sink.beginSdkInvocation(round, { + endpointFormat: 'agent-sdk', + target: 'agent-sdk://local/query', + bodyText: JSON.stringify({ + model: input.model, + system: [input.systemPrompt, input.threadPersona ?? ''].filter(Boolean).join('\n'), + instructions: input.contextInstructions, + input: input.prompt, + tools: input.tools.map((tool) => ({ + name: bridgedToolModelName(tool.name), + description: tool.description, + input_schema: tool.inputSchema + })), + attachments: { + count: input.images.length, + images: input.images + }, + approvalPolicy: input.approvalPolicy, + ...(input.sandboxMode ? { sandboxMode: input.sandboxMode } : {}) + }), + ...(input.oauthToken ? { secretValues: [input.oauthToken] } : {}), + delegated: input.delegated + }) + return { + sink, + round, + record, + currentText: '', + currentReasoning: '' + } + } catch { + if (round) void sink.finish(round).catch(() => undefined) + warnAgentSdkTraceFailure() + return undefined + } +} + +function captureAgentSdkTraceDraft( + trace: AgentSdkTrace | undefined, + draft: RuntimeEventDraft +): void { + if (!trace) return + try { + const item = itemOf(draft) + if (draft.kind === 'assistant_text_delta' && item?.kind === 'assistant_text') { + trace.currentText += item.text + trace.sink.captureChunk(trace.round, { + kind: 'assistant_text_delta', + text: item.text + }) + return + } + if ( + draft.kind === 'assistant_reasoning_delta' && + item?.kind === 'assistant_reasoning' + ) { + trace.currentReasoning += item.text + trace.sink.captureChunk(trace.round, { + kind: 'assistant_reasoning_delta', + text: item.text + }) + return + } + if (draft.kind === 'item_created' && item?.kind === 'assistant_text') { + const missing = item.text.startsWith(trace.currentText) + ? item.text.slice(trace.currentText.length) + : trace.currentText === item.text + ? '' + : item.text + if (missing) { + trace.sink.captureChunk(trace.round, { + kind: 'assistant_text_delta', + text: missing + }) + } + trace.currentText = '' + return + } + if (draft.kind === 'item_created' && item?.kind === 'assistant_reasoning') { + const missing = item.text.startsWith(trace.currentReasoning) + ? item.text.slice(trace.currentReasoning.length) + : trace.currentReasoning === item.text + ? '' + : item.text + if (missing) { + trace.sink.captureChunk(trace.round, { + kind: 'assistant_reasoning_delta', + text: missing + }) + } + trace.currentReasoning = '' + return + } + if (draft.kind === 'item_created' && item?.kind === 'tool_call') { + trace.sink.captureChunk(trace.round, { + kind: 'tool_call_complete', + callId: item.callId, + toolName: item.toolName, + arguments: item.arguments + }) + return + } + if (draft.kind === 'tool_call_finished' && item?.kind === 'tool_result') { + trace.sink.captureToolResult?.(trace.round, { + callId: item.callId, + toolName: item.toolName, + output: traceOutputText(item.output), + isError: item.isError + }) + return + } + if (draft.kind === 'usage') { + trace.sink.captureChunk(trace.round, { + kind: 'usage', + usage: draft.usage + }) + } + } catch { + warnAgentSdkTraceFailure() + } +} + +function traceOutputText(value: unknown): string { + if (typeof value === 'string') return value + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +async function finishAgentSdkTrace( + trace: AgentSdkTrace | undefined, + result: + | { kind: 'completed' } + | { kind: 'failed'; error: unknown } + | { kind: 'error'; error: unknown } +): Promise { + if (!trace) return + try { + if (result.kind === 'completed') { + trace.sink.captureChunk(trace.round, { kind: 'completed', stopReason: 'stop' }) + } else { + trace.sink.captureChunk(trace.round, { + kind: 'error', + message: result.error instanceof Error ? result.error.message : String(result.error) + }) + if (result.kind === 'error') { + trace.sink.captureTransportError(trace.record, result.error) + } else { + trace.sink.captureChunk(trace.round, { + kind: 'completed', + stopReason: 'error' + }) + } + } + await trace.sink.finish(trace.round) + } catch { + warnAgentSdkTraceFailure() + } +} + +let agentSdkTraceFailureWarned = false + +function warnAgentSdkTraceFailure(): void { + if (agentSdkTraceFailureWarned) return + agentSdkTraceFailureWarned = true + console.warn( + '[kun:agent-sdk] model request observability capture failed; the SDK turn continues unchanged' + ) +} + +function estimatedTokens(text: string): number { + return text ? Math.ceil(Buffer.byteLength(text, 'utf8') / 4) : 0 +} + +const CLAUDE_CREDENTIAL_PATTERN = /sk-ant-(?:oat|api)[\w-]+/g + +function sanitizeAgentSdkError(error: unknown, oauthToken: string | undefined): string { + const message = error instanceof Error ? error.message : String(error) + const withoutKnownToken = oauthToken + ? message.split(oauthToken).join('[REDACTED]') + : message + return withoutKnownToken.replace(CLAUDE_CREDENTIAL_PATTERN, '[REDACTED]') +} + +export function agentSdkCapabilities(): DelegatedRuntimeCapabilities { + return { + nativeResume: true, + structuredStreaming: true, + kunTools: true, + externalApproval: true, + liveSteering: false, + nativeContextTelemetry: false, + fork: false + } +} + type PendingSdkAssistantDeltaEvent = Omit & { parts: string[] bytes: number diff --git a/kun/src/runtime/agent-sdk/sdk-context-assembler.test.ts b/kun/src/runtime/agent-sdk/sdk-context-assembler.test.ts index 865867157..01c46e5a8 100644 --- a/kun/src/runtime/agent-sdk/sdk-context-assembler.test.ts +++ b/kun/src/runtime/agent-sdk/sdk-context-assembler.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'vitest' import type { TurnItem } from '../../contracts/items.js' -import { buildHistoryTranscript, composeSdkPromptText } from './sdk-context-assembler.js' +import { + buildHistoryTranscript, + composeSdkPromptText, + SDK_HISTORY_OMISSION_MARKER +} from './sdk-context-assembler.js' function userMsg(turnId: string, text: string): TurnItem { return { @@ -24,6 +28,48 @@ function assistantMsg(turnId: string, text: string): TurnItem { } as unknown as TurnItem } +function compaction(turnId: string, summary: string): TurnItem { + return { + id: `item_${turnId}_compact`, + threadId: 'th', + turnId, + kind: 'compaction', + role: 'system', + status: 'completed', + createdAt: '2026-01-01T00:00:00.000Z', + summary, + replacedTokens: 10_000, + pinnedConstraints: [] + } as TurnItem +} + +function toolPair(turnId: string, callId: string, text: string): TurnItem[] { + return [{ + id: `item_${callId}`, + threadId: 'th', + turnId, + kind: 'tool_call', + role: 'assistant', + status: 'completed', + toolName: 'read', + toolKind: 'tool_call', + callId, + arguments: { path: text } + }, { + id: `result_${callId}`, + threadId: 'th', + turnId, + kind: 'tool_result', + role: 'tool', + status: 'completed', + toolName: 'read', + toolKind: 'tool_call', + callId, + output: text, + isError: false + }] as TurnItem[] +} + describe('buildHistoryTranscript', () => { test('returns empty string when there is no prior history', () => { const items = [userMsg('t2', 'only the current turn')] @@ -42,6 +88,77 @@ describe('buildHistoryTranscript', () => { // the live turn's own user text must NOT leak into the replayed history expect(transcript).not.toContain('current question') }) + + test('keeps newest history and marks omitted older history at the byte limit', () => { + const items = [ + userMsg('t1', `oldest-${'a'.repeat(1_800)}`), + assistantMsg('t1', `old-answer-${'b'.repeat(1_800)}`), + userMsg('t2', `recent-${'c'.repeat(900)}`), + assistantMsg('t2', `latest-answer-${'d'.repeat(900)}`), + userMsg('t3', 'current') + ] + const transcript = buildHistoryTranscript(items, 't3', 2_400) + expect(Buffer.byteLength(transcript, 'utf8')).toBeLessThanOrEqual(2_400) + expect(transcript).toContain(SDK_HISTORY_OMISSION_MARKER) + expect(transcript).toContain('latest-answer') + expect(transcript).not.toContain('oldest-') + expect(transcript).not.toContain('current') + }) + + test('keeps the labeled tail of one oversized newest item instead of backfilling older text', () => { + const transcript = buildHistoryTranscript([ + userMsg('t1', 'older-small-message-that-must-not-return'), + assistantMsg('t2', `latest-start-${'x'.repeat(3_000)}-LATEST-END`), + userMsg('t3', 'current') + ], 't3', 1_024) + expect(Buffer.byteLength(transcript, 'utf8')).toBeLessThanOrEqual(1_024) + expect(transcript).toContain(SDK_HISTORY_OMISSION_MARKER) + expect(transcript).toContain('[assistant] … ') + expect(transcript).not.toContain('older-small-message-that-must-not-return') + }) + + test('pins the latest compaction summary and excludes replaced source history', () => { + const items = [ + userMsg('t0', 'replaced source'), + compaction('t1', 'authoritative compacted state'), + userMsg('t2', 'recent decision'), + assistantMsg('t2', 'recent outcome'), + userMsg('t3', 'current') + ] + const transcript = buildHistoryTranscript(items, 't3', 2_048) + expect(transcript).toContain('[earlier summary] authoritative compacted state') + expect(transcript).toContain('recent decision') + expect(transcript).not.toContain('replaced source') + }) + + test('keeps an oversized compaction summary byte-safe with an omission marker', () => { + const transcript = buildHistoryTranscript([ + compaction('t1', `summary-${'界'.repeat(2_000)}`), + userMsg('t2', 'current') + ], 't2', 1_024) + expect(Buffer.byteLength(transcript, 'utf8')).toBeLessThanOrEqual(1_024) + expect(transcript).toContain('[earlier summary]') + expect(transcript).toContain(SDK_HISTORY_OMISSION_MARKER) + }) + + test('never retains a tool result without its matching call', () => { + const pair = toolPair('t2', 'call_recent', `recent-tool-${'x'.repeat(500)}`) + const orphan: TurnItem = { + ...(pair[1] as Extract), + id: 'orphan_result', + callId: 'missing_call', + output: 'orphan-output' + } + const transcript = buildHistoryTranscript([ + userMsg('t1', `older-${'o'.repeat(1_800)}`), + ...pair, + orphan, + userMsg('t3', 'current') + ], 't3', 2_048) + expect(transcript).toContain('[tool_call:read]') + expect(transcript).toContain('[tool_result:read]') + expect(transcript).not.toContain('orphan-output') + }) }) describe('composeSdkPromptText', () => { diff --git a/kun/src/runtime/agent-sdk/sdk-context-assembler.ts b/kun/src/runtime/agent-sdk/sdk-context-assembler.ts index 14d0550e9..17b93dbe2 100644 --- a/kun/src/runtime/agent-sdk/sdk-context-assembler.ts +++ b/kun/src/runtime/agent-sdk/sdk-context-assembler.ts @@ -5,17 +5,23 @@ * mode instructions unless we feed them in. This module builds those pieces as * plain text so the runtime can splice them into the SDK prompt. * - * Design: kun owns the canonical history, so every SDK turn is stateless from - * kun's side — we replay the prior conversation as a transcript preamble each - * turn instead of relying on the SDK's in-memory `resume` (which is lost on a - * provider switch or runtime restart). All functions here are pure and - * unit-tested; the runtime/factory do the impure data-loading and call these. + * Kun owns the canonical portable history. A provider-native session may carry + * the ordinary next turn, while this bounded handoff is used to seed a new + * native generation after a switch, compaction, import, or missing checkpoint. */ import type { TurnItem } from '../../contracts/items.js' +import { effectiveHistoryAfterLatestCompaction } from '../../loop/compaction-history.js' import { buildSessionTranscript } from '../../loop/session-summary.js' /** Default cap for the replayed history transcript (bytes). */ export const DEFAULT_SDK_HISTORY_TRANSCRIPT_MAX_BYTES = 48 * 1024 +export const SDK_HISTORY_OMISSION_MARKER = + '...[older history omitted to fit delegated context]' + +type HistoryChunk = { + text: string + truncationSource?: string +} /** * Render the prior conversation (everything BEFORE the current turn) as a @@ -28,8 +34,156 @@ export function buildHistoryTranscript( maxBytes: number = DEFAULT_SDK_HISTORY_TRANSCRIPT_MAX_BYTES ): string { const priorItems = items.filter((item) => item.turnId !== currentTurnId) - if (priorItems.length === 0) return '' - return buildSessionTranscript(priorItems, maxBytes).trim() + const effective = effectiveHistoryAfterLatestCompaction(priorItems) + if (effective.length === 0) return '' + + const limit = Math.max(1_024, Math.floor(maxBytes)) + const summary = effective[0]?.kind === 'compaction' && effective[0].replacedTokens > 0 + ? effective[0] + : undefined + const chunks = completeHistoryChunks(summary ? effective.slice(1) : effective) + const renderedSummary = summary ? renderChunk([summary]) : '' + const markerReserve = utf8Bytes(SDK_HISTORY_OMISSION_MARKER) + 1 + const summaryWasTruncated = utf8Bytes(renderedSummary) > limit - markerReserve + const summaryText = summaryWasTruncated + ? fitUtf8(renderedSummary, Math.max(1, limit - markerReserve)) + : renderedSummary + const selected: string[] = [] + let used = utf8Bytes(summaryText) + let omitted = summaryWasTruncated + + for (let index = chunks.length - 1; index >= 0; index -= 1) { + const chunk = chunks[index] + const separatorBytes = selected.length > 0 || summaryText ? 1 : 0 + if (used + separatorBytes + utf8Bytes(chunk.text) > limit) { + omitted = true + // Keep a contiguous newest-first tail. Skipping an oversized recent + // exchange and then admitting older small messages would recreate the + // long-session bug this handoff is meant to prevent. + if (selected.length === 0 && chunk.truncationSource) { + const available = limit - used - separatorBytes - markerReserve + const recentTail = truncateRecentChunk( + chunk.truncationSource, + Math.max(0, available) + ) + if (recentTail) { + selected.unshift(recentTail) + used += separatorBytes + utf8Bytes(recentTail) + } + } + break + } + selected.unshift(chunk.text) + used += separatorBytes + utf8Bytes(chunk.text) + } + + const sections = [ + ...(summaryText ? [summaryText] : []), + ...selected + ] + if (omitted) { + const markerBytes = utf8Bytes(SDK_HISTORY_OMISSION_MARKER) + const markerSeparator = sections.length > 0 ? 1 : 0 + while ( + sections.length > (summaryText ? 1 : 0) && + utf8Bytes(sections.join('\n')) + markerSeparator + markerBytes > limit + ) { + sections.splice(summaryText ? 1 : 0, 1) + } + const markerIndex = summaryText ? 1 : 0 + sections.splice(markerIndex, 0, SDK_HISTORY_OMISSION_MARKER) + } + return fitUtf8(sections.join('\n'), limit).trim() +} + +function completeHistoryChunks(items: readonly TurnItem[]): HistoryChunk[] { + const turnOrder: string[] = [] + const byTurn = new Map() + for (const item of items) { + let turnItems = byTurn.get(item.turnId) + if (!turnItems) { + turnItems = [] + byTurn.set(item.turnId, turnItems) + turnOrder.push(item.turnId) + } + turnItems.push(item) + } + const chunks: HistoryChunk[] = [] + for (const turnId of turnOrder) { + const turnItems = byTurn.get(turnId) ?? [] + const resultByCall = new Map>() + for (const item of turnItems) { + if (item.kind === 'tool_result' && isTerminal(item)) resultByCall.set(item.callId, item) + } + const included: TurnItem[] = [] + let containsToolInteraction = false + for (const item of turnItems) { + if (!isTerminal(item) || item.kind === 'tool_result') continue + if (item.kind === 'tool_call') { + const result = resultByCall.get(item.callId) + if (!result) continue + containsToolInteraction = true + included.push(item, result) + continue + } + included.push(item) + } + const text = renderChunk(included) + if (!text) continue + const lastItem = included.at(-1) + const truncationSource = !containsToolInteraction && lastItem + ? renderChunk([lastItem]) + : '' + chunks.push({ + text, + ...(truncationSource ? { truncationSource } : {}) + }) + } + return chunks +} + +function isTerminal(item: TurnItem): boolean { + return item.status === 'completed' || item.status === 'failed' +} + +function renderChunk(items: readonly TurnItem[]): string { + return buildSessionTranscript(items, 16 * 1024 * 1024).trim() +} + +function utf8Bytes(text: string): number { + return Buffer.byteLength(text, 'utf8') +} + +function fitUtf8(text: string, maxBytes: number): string { + if (utf8Bytes(text) <= maxBytes) return text + let out = '' + let used = 0 + for (const char of text) { + const bytes = utf8Bytes(char) + if (used + bytes > maxBytes) break + out += char + used += bytes + } + return out +} + +function truncateRecentChunk(text: string, maxBytes: number): string { + if (maxBytes <= 0) return '' + if (utf8Bytes(text) <= maxBytes) return text + const labelEnd = text.indexOf(']') + const label = labelEnd >= 0 ? text.slice(0, labelEnd + 1) : '[recent history]' + const separator = ' … ' + const contentBytes = maxBytes - utf8Bytes(label) - utf8Bytes(separator) + if (contentBytes <= 0) return '' + let out = '' + let used = 0 + for (const char of [...text].reverse()) { + const bytes = utf8Bytes(char) + if (used + bytes > contentBytes) break + out = char + out + used += bytes + } + return `${label}${separator}${out}` } export interface SdkPromptParts { diff --git a/kun/src/runtime/agent-sdk/sdk-options-builder.test.ts b/kun/src/runtime/agent-sdk/sdk-options-builder.test.ts index ff580fe90..35ff46a72 100644 --- a/kun/src/runtime/agent-sdk/sdk-options-builder.test.ts +++ b/kun/src/runtime/agent-sdk/sdk-options-builder.test.ts @@ -56,9 +56,21 @@ describe('buildScopedEnv', () => { test('does not mutate the input env', () => { const base = { ANTHROPIC_API_KEY: 'k' } - buildScopedEnv(base, 't') + buildScopedEnv(base, 'sk-ant-oat01-valid') expect(base.ANTHROPIC_API_KEY).toBe('k') }) + + test('rejects wrapped or malformed setup tokens without echoing the secret', () => { + const raw = 'Bearer sk-ant-oat01-should-not-leak' + expect(() => buildScopedEnv({}, raw)).toThrow( + 'Claude subscription token format is invalid' + ) + try { + buildScopedEnv({}, raw) + } catch (error) { + expect(String(error)).not.toContain('sk-ant-oat01-should-not-leak') + } + }) }) describe('mapApprovalPolicyToPermissionMode', () => { diff --git a/kun/src/runtime/agent-sdk/sdk-options-builder.ts b/kun/src/runtime/agent-sdk/sdk-options-builder.ts index c432662d5..d6977459e 100644 --- a/kun/src/runtime/agent-sdk/sdk-options-builder.ts +++ b/kun/src/runtime/agent-sdk/sdk-options-builder.ts @@ -59,6 +59,19 @@ const AUTH_OVERRIDE_ENV_KEYS: readonly string[] = [ 'CLAUDE_CODE_USE_ANTHROPIC_AWS' ] +const CLAUDE_OAUTH_TOKEN_PATTERN = /^sk-ant-oat[\w-]+$/ + +export function normalizeClaudeOAuthToken(raw: string | undefined): string | undefined { + const token = raw?.trim() + if (!token) return undefined + if (!CLAUDE_OAUTH_TOKEN_PATTERN.test(token)) { + throw new Error( + 'Claude subscription token format is invalid. Paste only the complete sk-ant-oat token value.' + ) + } + return token +} + /** * Produce a clean env for the SDK's Claude Code subprocess: strip anything that * would outrank the subscription token, then inject the token (when provided). @@ -71,7 +84,7 @@ export function buildScopedEnv( ): Record { const env: Record = { ...baseEnv } for (const key of AUTH_OVERRIDE_ENV_KEYS) delete env[key] - const token = oauthToken?.trim() + const token = normalizeClaudeOAuthToken(oauthToken) if (token) env.CLAUDE_CODE_OAUTH_TOKEN = token return env } diff --git a/kun/src/runtime/agent-sdk/sdk-tool-bridge.ts b/kun/src/runtime/agent-sdk/sdk-tool-bridge.ts index d212a0a81..e1fab9b4f 100644 --- a/kun/src/runtime/agent-sdk/sdk-tool-bridge.ts +++ b/kun/src/runtime/agent-sdk/sdk-tool-bridge.ts @@ -22,6 +22,9 @@ export interface BridgeableTool { name: string description: string inputSchema: Record + /** Original Kun capability provider retained across the SDK MCP rename. */ + providerId?: string + providerKind?: string } export interface KunToolResult { @@ -189,5 +192,9 @@ export function toSdkMcpServer( /** The `mcp____` names the model will see, for allowedTools wiring. */ export function bridgedToolModelNames(specs: readonly BridgedToolSpec[], serverName = 'kun'): string[] { - return specs.map((spec) => `mcp__${serverName}__${spec.name}`) + return specs.map((spec) => bridgedToolModelName(spec.name, serverName)) +} + +export function bridgedToolModelName(toolName: string, serverName = 'kun'): string { + return `mcp__${serverName}__${toolName}` } diff --git a/kun/src/runtime/antigravity/antigravity-cli-runtime.test.ts b/kun/src/runtime/antigravity/antigravity-cli-runtime.test.ts index 7b7c8c423..6bedc505b 100644 --- a/kun/src/runtime/antigravity/antigravity-cli-runtime.test.ts +++ b/kun/src/runtime/antigravity/antigravity-cli-runtime.test.ts @@ -40,6 +40,20 @@ describe('AntigravityCliRuntime', () => { expect(args).not.toContain('--dangerously-skip-permissions') }) + it('fails closed to plan mode when GUI approval cannot be surfaced', () => { + const args = buildAntigravityArgs({ + prompt: 'change files after approval', + model: 'gemini-3.6-flash', + effort: 'medium', + timeoutMs: 60_000, + planMode: false, + approvalPolicy: 'on-request', + sandboxMode: 'danger-full-access' + }) + expect(args).toEqual(expect.arrayContaining(['--mode', 'plan', '--sandbox'])) + expect(args).not.toContain('--dangerously-skip-permissions') + }) + it('maps Kun auto approval into the CLI while retaining workspace sandboxing', () => { const args = buildAntigravityArgs({ prompt: 'make the change', @@ -56,6 +70,8 @@ describe('AntigravityCliRuntime', () => { '--model', 'gemini-3.5-flash' ])) + expect(args).not.toContain('--continue') + expect(args.some((value) => value.startsWith('--conversation'))).toBe(false) }) it('forces delegated read-only children into plan and sandbox controls', async () => { @@ -191,6 +207,12 @@ describe('AntigravityCliRuntime', () => { transport: 'cli', endpointFormat: 'antigravity-cli', status: 'completed', + delegated: { + providerKind: 'antigravity-cli', + phase: 'portable', + contextManagement: 'sdk-managed', + nativeHistory: 'none' + }, request: { method: 'CLI', url: 'antigravity-cli://local/print' diff --git a/kun/src/runtime/antigravity/antigravity-cli-runtime.ts b/kun/src/runtime/antigravity/antigravity-cli-runtime.ts index 8b9d9b76a..6917c0017 100644 --- a/kun/src/runtime/antigravity/antigravity-cli-runtime.ts +++ b/kun/src/runtime/antigravity/antigravity-cli-runtime.ts @@ -6,7 +6,10 @@ import { makeAssistantTextItem } from '../../domain/item.js' import { normalizeTurnLimits, type TurnLimitsConfig } from '../../loop/turn-limits.js' import type { SessionStore } from '../../ports/session-store.js' import type { ThreadStore } from '../../ports/thread-store.js' -import type { ModelRequestTraceRecord } from '../../contracts/model-request-trace.js' +import type { + ModelRequestTraceDelegated, + ModelRequestTraceRecord +} from '../../contracts/model-request-trace.js' import type { LlmDebugRound, LlmDebugSink @@ -18,7 +21,16 @@ import { composeSdkPromptText, DEFAULT_SDK_HISTORY_TRANSCRIPT_MAX_BYTES } from '../agent-sdk/sdk-context-assembler.js' -import type { DelegatedTurnRuntime } from '../delegated-turn-runtime.js' +import type { + DelegatedRuntimeCapabilities, + DelegatedTurnRuntime +} from '../delegated-turn-runtime.js' +import { + delegatedCapabilityFingerprint, + delegatedCredentialIdentity, + priorItemsForDelegatedTurn, + type DelegatedSessionCoordinator +} from '../delegated-session-binding.js' const DEFAULT_MODEL = 'gemini-3.6-flash' const MAX_STDOUT_BYTES = 8 * 1024 * 1024 @@ -42,6 +54,12 @@ export interface AntigravityCliRuntimeDeps { spawnFn?: typeof spawn /** Delegated read-only children must deny mutation regardless of parent defaults. */ enforceReadOnly?: boolean + sessionCoordinator?: DelegatedSessionCoordinator + contextProfile?: (model: string) => { + contextWindowTokens: number + softThresholdTokens: number + hardThresholdTokens: number + } } export function normalizeAntigravityModel(model: string | undefined): string { @@ -78,7 +96,7 @@ export function buildAntigravityArgs(input: { ] const denyMutation = input.planMode || - input.approvalPolicy === 'never' || + input.approvalPolicy !== 'auto' || input.sandboxMode === 'read-only' || input.sandboxMode === 'external-sandbox' if (denyMutation) { @@ -86,10 +104,6 @@ export function buildAntigravityArgs(input: { } else if (input.approvalPolicy === 'auto') { args.push('--dangerously-skip-permissions') if (input.sandboxMode !== 'danger-full-access') args.push('--sandbox') - } else if (input.sandboxMode !== 'danger-full-access') { - // Headless Antigravity cannot surface Kun's GUI approval gate. Preserve the - // requested sandbox and let the official CLI soft-deny interactive actions. - args.push('--sandbox') } return args } @@ -103,11 +117,28 @@ export class AntigravityCliRuntime implements DelegatedTurnRuntime { return !providerId || !this.deps.providerConfigs[providerId] } + capabilities(providerId: string | undefined): DelegatedRuntimeCapabilities | undefined { + if (!this.handlesProvider(providerId)) return undefined + return antigravityCapabilities() + } + async runTurn( threadId: string, turnId: string, signal: AbortSignal, providerId?: string + ): Promise<'completed' | 'failed' | 'aborted'> { + const execute = () => this.runTurnOwned(threadId, turnId, signal, providerId) + return this.deps.sessionCoordinator + ? this.deps.sessionCoordinator.runExclusive(threadId, execute) + : execute() + } + + private async runTurnOwned( + threadId: string, + turnId: string, + signal: AbortSignal, + providerId?: string ): Promise<'completed' | 'failed' | 'aborted'> { const thread = await this.deps.threadStore.get(threadId) const turn = thread?.turns.find((candidate) => candidate.id === turnId) @@ -164,6 +195,74 @@ export class AntigravityCliRuntime implements DelegatedTurnRuntime { approvalPolicy: thread.approvalPolicy, sandboxMode }) + const resolvedProviderId = providerId?.trim() || 'antigravity-cli' + const provider = providerId ? this.deps.providerConfigs[providerId] : undefined + const capabilities = antigravityCapabilities() + const preparation = this.deps.sessionCoordinator + ? await this.deps.sessionCoordinator.prepare({ + threadId, + route: { + providerKind: 'antigravity-cli', + providerId: resolvedProviderId, + credentialIdentity: delegatedCredentialIdentity({ + providerId: resolvedProviderId, + accountId: turn.accountId || thread.accountId, + credentialSourceId: provider?.credentialSourceId + }), + workspace: thread.workspace, + model, + capabilityFingerprint: delegatedCapabilityFingerprint({ + systemPrompt: this.deps.systemPrompt?.trim() || '', + threadPersona: thread.systemPrompt?.trim() || '', + effort, + planMode, + approvalPolicy: thread.approvalPolicy, + sandboxMode, + capabilities + }), + // The supported non-interactive CLI output does not provide a + // validated conversation id. Never use process-global --continue. + continuationMode: 'portable' + }, + priorItems: priorItemsForDelegatedTurn(items, turnId) + }) + : undefined + await this.deps.events.record({ + kind: 'delegated_runtime', + threadId, + turnId, + providerKind: 'antigravity-cli', + providerId: resolvedProviderId, + phase: 'portable', + ...(preparation?.rebaseReason ? { reason: preparation.rebaseReason } : {}), + capabilities + }) + const contextProfile = this.deps.contextProfile?.(model) + if (contextProfile) { + const system = estimateAntigravityTokens(instructionBlocks.join('\n')) + const messages = estimateAntigravityTokens(prompt) - system + await this.deps.events.record({ + kind: 'context_snapshot', + threadId, + turnId, + model, + providerId: resolvedProviderId, + stepIndex: 0, + ...contextProfile, + estimatedInputTokens: system + Math.max(0, messages), + breakdown: { + tools: 0, + system, + skills: 0, + messages: Math.max(0, messages), + other: 0 + }, + toolCount: 0, + activeSkillIds: [], + contextManagement: 'sdk-managed', + nativeHistory: 'none' + }) + } let trace = startAntigravityTrace(this.deps.debugSink, { threadId, turnId, @@ -173,7 +272,15 @@ export class AntigravityCliRuntime implements DelegatedTurnRuntime { effort, planMode, approvalPolicy: thread.approvalPolicy, - sandboxMode + sandboxMode, + delegated: { + providerKind: 'antigravity-cli', + phase: 'portable', + ...(preparation?.rebaseReason ? { reason: preparation.rebaseReason } : {}), + contextManagement: 'sdk-managed', + nativeHistory: 'none', + capabilities + } }) try { @@ -223,6 +330,18 @@ export class AntigravityCliRuntime implements DelegatedTurnRuntime { }) ) await this.deps.turns.finishTurn({ threadId, turnId, status: 'completed' }) + if (preparation && this.deps.sessionCoordinator) { + try { + await this.deps.sessionCoordinator.commit({ + preparation, + committedItems: await this.deps.sessionStore.loadItems(threadId), + lastCommittedTurnId: turnId + }) + } catch { + // Portable history remains authoritative if the disposable binding + // cannot be recorded. + } + } return 'completed' } catch (error) { await finishAntigravityTrace(trace, { kind: 'error', error }) @@ -245,6 +364,22 @@ export class AntigravityCliRuntime implements DelegatedTurnRuntime { } } +function estimateAntigravityTokens(text: string): number { + return text ? Math.ceil(Buffer.byteLength(text, 'utf8') / 4) : 0 +} + +export function antigravityCapabilities(): DelegatedRuntimeCapabilities { + return { + nativeResume: false, + structuredStreaming: false, + kunTools: false, + externalApproval: false, + liveSteering: false, + nativeContextTelemetry: false, + fork: false + } +} + function runAntigravityProcess(input: { binaryPath: string args: string[] @@ -336,6 +471,7 @@ function startAntigravityTrace( planMode: boolean approvalPolicy: string sandboxMode: string + delegated: ModelRequestTraceDelegated } ): AntigravityTrace | undefined { if (!sink) return undefined @@ -357,7 +493,8 @@ function startAntigravityTrace( mode: input.planMode ? 'plan' : 'agent', approvalPolicy: input.approvalPolicy, sandboxMode: input.sandboxMode - }) + }), + delegated: input.delegated }) return { sink, round, record } } catch { diff --git a/kun/src/runtime/cursor/cursor-sdk-event-mapper.ts b/kun/src/runtime/cursor/cursor-sdk-event-mapper.ts index 439d01782..c942b90bf 100644 --- a/kun/src/runtime/cursor/cursor-sdk-event-mapper.ts +++ b/kun/src/runtime/cursor/cursor-sdk-event-mapper.ts @@ -1,5 +1,6 @@ import type { SDKMessage, TokenUsage } from '@cursor/sdk' import { DEFAULT_MODEL_STREAM_LIMITS } from '../../adapters/model/model-stream-resource-budget.js' +import type { TurnItem } from '../../contracts/items.js' import type { UsageSnapshot } from '../../contracts/usage.js' import { makeAssistantReasoningItem, @@ -165,6 +166,28 @@ export class CursorSdkEventMapper { return this.textParts.join('') } + get runningTextItem(): TurnItem | undefined { + if (!this.textItemId || this.textParts.length === 0) return undefined + return makeAssistantTextItem({ + id: this.textItemId, + threadId: this.ctx.threadId, + turnId: this.ctx.turnId, + text: this.text, + status: 'running' + }) + } + + get runningReasoningItem(): TurnItem | undefined { + if (!this.reasoningItemId || this.reasoningParts.length === 0) return undefined + return makeAssistantReasoningItem({ + id: this.reasoningItemId, + threadId: this.ctx.threadId, + turnId: this.ctx.turnId, + text: this.reasoningParts.join(''), + status: 'running' + }) + } + map(message: SDKMessage): RuntimeEventDraft[] { this.consumeEvent(message) switch (message.type) { diff --git a/kun/src/runtime/cursor/cursor-sdk-runtime-factory.test.ts b/kun/src/runtime/cursor/cursor-sdk-runtime-factory.test.ts new file mode 100644 index 000000000..a0c6d6373 --- /dev/null +++ b/kun/src/runtime/cursor/cursor-sdk-runtime-factory.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, test, vi } from 'vitest' +import type { + AgentOptions, + Run, + RunResult, + SDKAgent, + SDKMessage +} from '@cursor/sdk' +import { CapabilityRegistry } from '../../adapters/tool/capability-registry.js' +import { LocalToolHost } from '../../adapters/tool/local-tool-host.js' +import { LlmDebugRecorder } from '../../services/llm-debug-recorder.js' +import { + createCursorSdkRuntime, + type CursorSdkRuntimeFactoryDeps +} from './cursor-sdk-runtime-factory.js' +import type { CursorSdkApi } from './cursor-sdk-runtime.js' + +function messages(values: SDKMessage[]): AsyncGenerator { + return (async function* () { + for (const value of values) yield value + })() +} + +function completedRun(): Run { + const result: RunResult = { + id: 'run_1', + status: 'finished', + result: 'done' + } + return { + id: 'run_1', + agentId: 'agent_1', + supports: (operation) => operation === 'stream' || operation === 'wait' || operation === 'cancel', + unsupportedReason: () => undefined, + stream: () => messages([{ + type: 'assistant', + agent_id: 'agent_1', + run_id: 'run_1', + message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] } + }]), + conversation: async () => [], + wait: async () => result, + cancel: async () => undefined, + status: result.status, + onDidChangeStatus: () => () => undefined, + result: result.result, + error: undefined, + model: undefined, + durationMs: undefined, + usage: undefined, + git: undefined, + createdAt: 1 + } +} + +describe('Cursor SDK runtime factory', () => { + test('bridges policy-filtered MCP and extension tools through Kun ToolHost', async () => { + const mcpExecute = vi.fn(async (args: Record) => ({ + output: { server: args.serverId, ok: true } + })) + const extensionExecute = vi.fn(async () => ({ output: 'extension result' })) + const registry = new CapabilityRegistry([{ + id: 'mcp:facade', + kind: 'mcp', + enabled: true, + available: true, + tools: [LocalToolHost.defineTool({ + name: 'mcp_call_tool', + description: 'Call an MCP tool through Kun', + inputSchema: { + type: 'object', + properties: { serverId: { type: 'string' } }, + required: ['serverId'] + }, + sideEffect: 'read-only', + execute: mcpExecute + })] + }, { + id: 'extension:demo', + kind: 'extension', + enabled: true, + available: true, + tools: [LocalToolHost.defineTool({ + name: 'extension_render', + description: 'Render through a Kun extension', + inputSchema: { type: 'object' }, + execute: extensionExecute + })] + }]) + const toolHost = new LocalToolHost({ registry }) + const createOptions: AgentOptions[] = [] + const sentMessages: unknown[] = [] + const recorded: unknown[] = [] + const updatedMetadata: unknown[] = [] + let bridgedToolResult: unknown + const debugSink = new LlmDebugRecorder() + const agent = { + agentId: 'agent_1', + model: { id: 'auto' }, + send: async (message: unknown) => { + sentMessages.push(message) + bridgedToolResult = await createOptions[0]?.local?.customTools?.mcp_call_tool?.execute( + { serverId: 'docs' }, + { toolCallId: 'cursor-mcp-call' } + ) + return completedRun() + }, + close: vi.fn(), + reload: async () => undefined, + listArtifacts: async () => [], + downloadArtifact: async () => Buffer.alloc(0), + [Symbol.asyncDispose]: async () => undefined + } as SDKAgent + const sdk: CursorSdkApi = { + Agent: { + create: async (options) => { + createOptions.push(options) + return agent + }, + resume: async () => agent + } + } + const thread = { + id: 'thread_1', + title: 'Cursor bridge', + workspace: '/tmp/cursor-bridge', + model: 'auto', + mode: 'agent', + approvalPolicy: 'always', + sandboxMode: 'workspace-write', + systemPrompt: 'Thread persona', + turns: [{ id: 'turn_1', model: 'auto', mode: 'agent' }] + } + const userItem = { + id: 'user_1', + threadId: 'thread_1', + turnId: 'turn_1', + role: 'user', + status: 'completed', + createdAt: '2026-07-25T00:00:00.000Z', + kind: 'user_message', + text: 'Use the MCP server' + } + const approvalGate = { + request: vi.fn(async () => 'allow' as const), + decide: vi.fn(() => true), + reserveDecision: vi.fn(() => true), + commitDecision: vi.fn(() => true), + rollbackDecision: vi.fn(() => true), + expire: vi.fn(() => true), + pending: vi.fn(() => []), + get: vi.fn(() => undefined) + } + const runtime = createCursorSdkRuntime({ + registry, + toolHost, + providerConfigs: { + 'cursor-subscription': { kind: 'cursor-sdk', apiKey: 'cursor-secret' } + }, + providerIds: new Set(['cursor-subscription']), + defaultIsCursor: false, + defaultModel: 'auto', + defaultApprovalPolicy: 'always', + defaultSandboxMode: 'workspace-write', + systemPrompt: 'Kun canonical system prompt', + threadStore: { get: async () => thread } as never, + sessionStore: { + loadItems: async () => [userItem], + loadEventsSince: async () => [] + } as never, + turns: { + applyItem: async () => undefined, + updateItem: async () => undefined, + updateTurnMetadata: async (_threadId: string, _turnId: string, metadata: unknown) => { + updatedMetadata.push(metadata) + }, + finishTurn: async () => undefined + } as never, + events: { + record: async (event: unknown) => { + recorded.push(event) + return event + } + } as never, + ids: { next: (prefix) => `${prefix}_1` }, + debugSink, + approvalGate, + instructionRuntime: { + resolveTurn: async () => ({ + instruction: 'Workspace AGENTS.md instruction', + sources: [{ kind: 'workspace', path: '/tmp/cursor-bridge/AGENTS.md' }], + injectedBytes: 31 + }) + } as never, + loadSdk: async () => sdk + } satisfies CursorSdkRuntimeFactoryDeps) + + await expect(runtime.runTurn( + 'thread_1', + 'turn_1', + new AbortController().signal, + 'cursor-subscription' + )).resolves.toBe('completed') + + const customTools = createOptions[0]?.local?.customTools + expect(Object.keys(customTools ?? {}).sort()).toEqual([ + 'extension_render', + 'mcp_call_tool' + ]) + expect(String(sentMessages[0])).toContain('Kun canonical system prompt') + expect(String(sentMessages[0])).toContain('Thread persona') + expect(String(sentMessages[0])).toContain('Workspace AGENTS.md instruction') + expect(String(sentMessages[0])).toContain('Kun-managed tools are available') + expect(updatedMetadata).toContainEqual(expect.objectContaining({ + instructionInjectionBytes: 31 + })) + + expect(bridgedToolResult).toEqual({ + content: [{ + type: 'text', + text: JSON.stringify({ server: 'docs', ok: true }, null, 2) + }] + }) + await expect(customTools?.mcp_call_tool?.execute( + { serverId: 'late' }, + { toolCallId: 'cursor-late-call' } + )).resolves.toMatchObject({ + isError: true, + content: [{ type: 'text', text: 'tool call aborted before start' }] + }) + expect(mcpExecute).toHaveBeenCalledWith( + { serverId: 'docs' }, + expect.objectContaining({ + threadId: 'thread_1', + turnId: 'turn_1', + workspace: '/tmp/cursor-bridge', + approvalPolicy: 'always', + sandboxMode: 'workspace-write' + }), + expect.any(Function) + ) + expect(approvalGate.request).toHaveBeenCalled() + expect(recorded).toContainEqual(expect.objectContaining({ + kind: 'approval_requested', + toolName: 'mcp_call_tool' + })) + expect(recorded).toContainEqual(expect.objectContaining({ + kind: 'delegated_runtime', + capabilities: expect.objectContaining({ + kunTools: true, + externalApproval: true + }) + })) + + const trace = debugSink.snapshot()[0]?.exchanges[0] + expect(trace?.toolCatalog).toEqual(expect.arrayContaining([ + { + name: 'mcp_call_tool', + providerId: 'mcp:facade', + providerKind: 'mcp' + }, + { + name: 'extension_render', + providerId: 'extension:demo', + providerKind: 'extension' + } + ])) + }) +}) diff --git a/kun/src/runtime/cursor/cursor-sdk-runtime-factory.ts b/kun/src/runtime/cursor/cursor-sdk-runtime-factory.ts new file mode 100644 index 000000000..46e10f0ec --- /dev/null +++ b/kun/src/runtime/cursor/cursor-sdk-runtime-factory.ts @@ -0,0 +1,433 @@ +import type { CapabilityRegistry } from '../../adapters/tool/capability-registry.js' +import type { AttachmentStore } from '../../attachments/attachment-store.js' +import type { + ApprovalPolicy, + SandboxMode +} from '../../contracts/policy.js' +import type { ThreadRecord } from '../../contracts/threads.js' +import type { TurnItem } from '../../contracts/items.js' +import { makeUserInputItem } from '../../domain/item.js' +import type { ApprovalRequest } from '../../domain/approval.js' +import type { InstructionRuntime } from '../../instructions/instruction-runtime.js' +import { + DESIGN_MODE_INSTRUCTION, + SVG_ARTIFACT_ALLOWED_TOOL_NAMES, + SVG_ARTIFACT_MODE_INSTRUCTION +} from '../../loop/design-mode.js' +import { + PLAN_MODE_INSTRUCTION, + goalContinuationInstruction, + isStalePlanContext, + memoryInstructions, + todoContinuationInstruction +} from '../../loop/agent-loop.js' +import type { MemoryStore } from '../../memory/memory-store.js' +import type { ApprovalGate } from '../../ports/approval-gate.js' +import type { + GuiPlanContext, + ToolHost, + ToolHostContext +} from '../../ports/tool-host.js' +import type { + UserInputGate, + UserInputRequest, + UserInputResolution +} from '../../ports/user-input-gate.js' +import { awaitAbortableGate } from '../../services/interactive-gate.js' +import type { SkillRuntime } from '../../skills/skill-runtime.js' +import { + DEFAULT_SANDBOX_MODE +} from '../../contracts/policy.js' +import { + CursorSdkRuntime, + type CursorSdkRuntimeDeps +} from './cursor-sdk-runtime.js' +import { + buildCursorCustomTools, + selectCursorBridgeTools +} from './cursor-sdk-tool-bridge.js' + +const CURSOR_KUN_TOOL_INSTRUCTION = [ + 'Kun-managed tools are available through Cursor custom tools.', + 'Use these tools for Kun capabilities such as MCP, extensions, skills, memory, media, GUI input, and delegation.', + 'Tool execution remains governed by Kun approval and sandbox policy.' +].join(' ') + +export interface CursorSdkRuntimeFactoryDeps extends Omit< + CursorSdkRuntimeDeps, + 'loadKunTurnContext' +> { + registry: CapabilityRegistry + toolHost?: ToolHost + defaultApprovalPolicy: ApprovalPolicy + defaultSandboxMode?: SandboxMode + skillRuntime?: SkillRuntime + instructionRuntime?: InstructionRuntime + memoryStore?: MemoryStore + userInputGate?: UserInputGate + approvalGate?: ApprovalGate + nowIso?: () => string + toolContextBoundary?: Pick< + ToolHostContext, + | 'allowedProviderIds' + | 'allowedToolNames' + | 'blockedProviderIds' + | 'blockedToolNames' + | 'blockedSkillIds' + > +} + +export function createCursorSdkRuntime( + deps: CursorSdkRuntimeFactoryDeps +): CursorSdkRuntime { + const { + registry, + toolHost, + defaultApprovalPolicy, + defaultSandboxMode, + skillRuntime, + instructionRuntime, + memoryStore, + userInputGate, + approvalGate, + nowIso: configuredNowIso, + toolContextBoundary, + ...runtimeDeps + } = deps + const activeSkillIdsByTurn = new Map() + const turnKey = (threadId: string, turnId: string): string => `${threadId}\u0000${turnId}` + const nowIso = (): string => configuredNowIso?.() ?? new Date().toISOString() + + const resolveActiveSkillIds = async ( + thread: ThreadRecord, + turn: ThreadRecord['turns'][number], + prompt: string + ): Promise => { + if (!skillRuntime) return activeSkillIdsByTurn.get(turnKey(thread.id, turn.id)) ?? [] + const resolution = await skillRuntime.resolveTurn({ + prompt, + workspace: thread.workspace, + threadId: thread.id, + turnId: turn.id, + ...(toolContextBoundary?.blockedSkillIds + ? { blockedSkillIds: toolContextBoundary.blockedSkillIds } + : {}) + }) + activeSkillIdsByTurn.set(turnKey(thread.id, turn.id), resolution.activeSkillIds) + return resolution.activeSkillIds + } + + const makeAwaitUserInput = ( + threadId: string, + turnId: string, + signal: AbortSignal + ): ToolHostContext['awaitUserInput'] => { + if (!userInputGate) return undefined + return async (input): Promise => { + const request: UserInputRequest = { + id: input.id, + threadId, + turnId, + itemId: input.itemId, + prompt: input.prompt, + questions: input.questions + } + const pending = userInputGate.request(request) + const item = makeUserInputItem({ + id: input.itemId, + threadId, + turnId, + inputId: input.id, + prompt: input.prompt, + questions: input.questions + }) + try { + await deps.turns.applyItem(threadId, item) + await deps.events.record({ + kind: 'user_input_requested', + threadId, + turnId, + itemId: item.id, + inputId: input.id, + status: 'pending', + prompt: input.prompt, + questions: input.questions + }) + } catch (error) { + userInputGate.resolve(input.id, { status: 'cancelled' }) + void pending.catch(() => undefined) + throw error + } + let resolution: UserInputResolution + try { + resolution = await awaitAbortableGate( + pending, + signal, + () => { userInputGate.resolve(input.id, { status: 'cancelled' }) }, + 'cancelled while awaiting Cursor SDK tool input' + ) + } catch { + resolution = { status: 'cancelled' } + } + await deps.turns.updateItem(threadId, item.id, { + status: resolution.status, + finishedAt: nowIso(), + ...(resolution.status === 'submitted' ? { answers: resolution.answers } : {}) + } as Partial) + const alreadyRecorded = (await deps.sessionStore.loadEventsSince(threadId, 0)).some( + (event) => event.kind === 'user_input_resolved' && event.inputId === input.id + ) + if (!alreadyRecorded) { + await deps.events.record({ + kind: 'user_input_resolved', + threadId, + turnId, + itemId: item.id, + inputId: input.id, + status: resolution.status, + prompt: input.prompt, + questions: input.questions, + ...(resolution.status === 'submitted' ? { answers: resolution.answers } : {}) + }) + } + return resolution + } + } + + const makeAwaitApproval = ( + approvalPolicy: ApprovalPolicy, + sandboxMode: SandboxMode | undefined, + signal: AbortSignal + ): ToolHostContext['awaitApproval'] => async (approval: ApprovalRequest) => { + if (approvalPolicy === 'never' || !approvalGate) return 'deny' + const pending = approvalGate.request(approval) + try { + await deps.events.record({ + kind: 'approval_requested', + threadId: approval.threadId, + turnId: approval.turnId, + approvalId: approval.id, + toolName: approval.toolName, + status: 'pending', + approvalPolicy, + sandboxMode: sandboxMode ?? DEFAULT_SANDBOX_MODE, + summary: approval.summary + }) + return await awaitAbortableGate( + pending, + signal, + () => { approvalGate.expire(approval.id, 'Cursor SDK turn aborted while awaiting approval') }, + 'cancelled while awaiting Cursor SDK tool approval' + ) + } catch { + approvalGate.expire(approval.id, 'Cursor SDK tool approval failed') + void pending.catch(() => undefined) + return 'deny' + } + } + + const toolContext = (input: { + thread: ThreadRecord + turn: ThreadRecord['turns'][number] + signal: AbortSignal + activeSkillIds: readonly string[] + listing?: boolean + }): ToolHostContext => { + const plan = resolveCursorPlanContext(input.thread, input.turn.id) + const dedicatedSvgTurn = input.turn.guiDesignArtifact?.kind === 'svg' + const allowedToolNames = intersectAllowedToolNames( + toolContextBoundary?.allowedToolNames, + dedicatedSvgTurn ? SVG_ARTIFACT_ALLOWED_TOOL_NAMES : undefined + ) + const approvalPolicy = runtimeDeps.enforceReadOnly === true + ? 'never' + : input.thread.approvalPolicy ?? defaultApprovalPolicy + const sandboxMode = runtimeDeps.enforceReadOnly === true + ? 'read-only' + : input.thread.sandboxMode ?? defaultSandboxMode ?? DEFAULT_SANDBOX_MODE + const awaitUserInput = makeAwaitUserInput( + input.thread.id, + input.turn.id, + input.signal + ) + return { + threadId: input.thread.id, + turnId: input.turn.id, + workspace: input.thread.workspace, + approvalPolicy, + sandboxMode, + abortSignal: input.signal, + ...toolContextBoundary, + ...(plan.planMode ? { threadMode: 'plan' as const } : {}), + ...(plan.guiPlan ? { guiPlan: plan.guiPlan } : {}), + ...(input.turn.guiDesignCanvas ? { guiDesignCanvas: true } : {}), + ...(input.turn.guiDesignMode ? { guiDesignMode: true } : {}), + ...(input.turn.guiDesignArtifact + ? { guiDesignArtifact: input.turn.guiDesignArtifact } + : {}), + ...(input.thread.toolCatalogEpoch + ? { extensionToolCatalogEpoch: input.thread.toolCatalogEpoch } + : {}), + activeSkillIds: input.activeSkillIds, + ...(allowedToolNames ? { allowedToolNames } : {}), + ...(awaitUserInput ? { awaitUserInput } : {}), + awaitApproval: input.listing + ? async () => 'deny' + : makeAwaitApproval(approvalPolicy, sandboxMode, input.signal) + } + } + + const loadKunTurnContext: NonNullable< + CursorSdkRuntimeDeps['loadKunTurnContext'] + > = async ({ threadId, turnId, userText, signal }) => { + const thread = await deps.threadStore.get(threadId) + const turn = thread?.turns.find((candidate) => candidate.id === turnId) + if (!thread || !turn) throw new Error('Cursor SDK Kun tool context is unavailable') + + const skillResolution = skillRuntime + ? await skillRuntime.resolveTurn({ + prompt: userText, + workspace: thread.workspace, + threadId, + turnId, + ...(toolContextBoundary?.blockedSkillIds + ? { blockedSkillIds: toolContextBoundary.blockedSkillIds } + : {}) + }) + : undefined + const activeSkillIds = skillResolution?.activeSkillIds ?? [] + activeSkillIdsByTurn.set(turnKey(threadId, turnId), activeSkillIds) + const availableSkillIds = typeof skillRuntime?.availableSkillIdsForWorkspace === 'function' + ? (await skillRuntime.availableSkillIdsForWorkspace(thread.workspace)) + .filter((id) => !toolContextBoundary?.blockedSkillIds?.includes(id)) + : activeSkillIds + const listingSkillIds = [...new Set([...activeSkillIds, ...availableSkillIds])] + const listingContext = toolContext({ + thread, + turn, + signal, + activeSkillIds: listingSkillIds, + listing: true + }) + if (toolHost) { + // Run the host preparation hook first so turn-scoped extension + // contributions are registered before the canonical catalog snapshot. + await toolHost.listTools(listingContext) + } + const tools = toolHost + ? selectCursorBridgeTools(registry.listTools(listingContext)) + : [] + + const instructionResolution = instructionRuntime + ? await instructionRuntime.resolveTurn({ workspace: thread.workspace }) + : undefined + if (instructionResolution) { + await deps.turns.updateTurnMetadata(threadId, turnId, { + injectedInstructionSources: instructionResolution.sources, + instructionInjectionBytes: instructionResolution.injectedBytes + }) + } + let memoryBlocks: string[] = [] + if (memoryStore && userText.trim()) { + const memories = await memoryStore.retrieve({ + query: userText, + workspace: thread.workspace, + limit: 8 + }) + memoryStore.setLastInjected(memories.map((memory) => memory.id)) + memoryBlocks = memoryInstructions(memories) + } + const plan = resolveCursorPlanContext(thread, turnId) + const goalInstruction = plan.planMode ? null : goalContinuationInstruction(thread.goal) + const todoInstruction = plan.planMode ? null : todoContinuationInstruction(thread.todos) + const instructionBlocks = [ + ...(plan.planMode ? [PLAN_MODE_INSTRUCTION] : []), + ...(turn.guiDesignArtifact?.kind === 'svg' + ? [SVG_ARTIFACT_MODE_INSTRUCTION] + : turn.guiDesignMode + ? [DESIGN_MODE_INSTRUCTION] + : []), + ...(instructionResolution?.instruction ? [instructionResolution.instruction] : []), + ...(goalInstruction ? [goalInstruction] : []), + ...(todoInstruction ? [todoInstruction] : []), + ...memoryBlocks, + ...(skillResolution?.catalogInstruction ? [skillResolution.catalogInstruction] : []), + ...(skillResolution?.instructions ?? []), + ...(tools.length ? [CURSOR_KUN_TOOL_INSTRUCTION] : []) + ] + const customTools = toolHost + ? buildCursorCustomTools(tools, async (toolName, args, toolCallId) => { + const latestThread = await deps.threadStore.get(threadId) + const latestTurn = latestThread?.turns.find((candidate) => candidate.id === turnId) + if (!latestThread || !latestTurn) { + return { output: 'Cursor SDK Kun tool context expired', isError: true } + } + const latestActiveSkillIds = await resolveActiveSkillIds( + latestThread, + latestTurn, + userText + ) + const context = toolContext({ + thread: latestThread, + turn: latestTurn, + signal, + activeSkillIds: latestActiveSkillIds + }) + try { + const result = await toolHost.execute({ + callId: toolCallId?.trim() || deps.ids.next('call_cursor_sdk'), + toolName, + arguments: args + }, context) + if (result.item.kind !== 'tool_result') { + return { + output: `Kun tool ${toolName} returned an invalid result item`, + isError: true + } + } + return { output: result.item.output, isError: result.item.isError } + } catch (error) { + return { + output: error instanceof Error ? error.message : String(error), + isError: true + } + } + }) + : {} + + return { + instructionBlocks, + activeSkillIds: [...(skillResolution?.activeSkillIds ?? activeSkillIds)], + tools, + customTools + } + } + + return new CursorSdkRuntime({ + ...runtimeDeps, + ...(toolHost ? { loadKunTurnContext } : {}) + }) +} + +function resolveCursorPlanContext( + thread: ThreadRecord, + turnId: string +): { planMode: boolean; guiPlan?: GuiPlanContext } { + const turn = thread.turns.find((entry) => entry.id === turnId) + const candidate = turn?.guiPlan ? ({ ...turn.guiPlan, turnId } as GuiPlanContext) : undefined + const guiPlan = candidate && !isStalePlanContext(candidate, thread.workspace) + ? candidate + : undefined + const planMode = (turn?.mode ?? thread.mode) === 'plan' || Boolean(guiPlan) + return { planMode, ...(guiPlan ? { guiPlan } : {}) } +} + +function intersectAllowedToolNames( + first: readonly string[] | undefined, + second: readonly string[] | undefined +): readonly string[] | undefined { + if (!first) return second + if (!second) return first + const secondSet = new Set(second) + return first.filter((name) => secondSet.has(name)) +} diff --git a/kun/src/runtime/cursor/cursor-sdk-runtime.test.ts b/kun/src/runtime/cursor/cursor-sdk-runtime.test.ts index 251aa8155..47d0f466f 100644 --- a/kun/src/runtime/cursor/cursor-sdk-runtime.test.ts +++ b/kun/src/runtime/cursor/cursor-sdk-runtime.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test, vi } from 'vitest' +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type { AgentOptions, Run, @@ -6,14 +9,24 @@ import type { SDKAgent, SDKMessage } from '@cursor/sdk' +import type { TurnItem } from '../../contracts/items.js' import { LlmDebugRecorder } from '../../services/llm-debug-recorder.js' import { CursorSdkRuntime, + cursorSdkCapabilities, cursorAgentExecutionOptions, sanitizeCursorSdkError, type CursorSdkApi, + type CursorKunTurnContext, type CursorSdkRuntimeDeps } from './cursor-sdk-runtime.js' +import { + DelegatedSessionCoordinator, + FileDelegatedSessionBindingStore, + delegatedCapabilityFingerprint, + delegatedCredentialIdentity, + delegatedHistoryDigest +} from '../delegated-session-binding.js' function messages(values: SDKMessage[]): AsyncGenerator { return (async function* () { @@ -67,12 +80,22 @@ function harness(input: { debugSink?: LlmDebugRecorder turnLimits?: { maxWallTimeMs?: number } loadError?: Error + sessionCoordinator?: CursorSdkRuntimeDeps['sessionCoordinator'] + omitLocalStore?: boolean + kunContext?: CursorKunTurnContext + contextProfile?: CursorSdkRuntimeDeps['contextProfile'] + streamLimits?: CursorSdkRuntimeDeps['streamLimits'] }) { const applied: unknown[] = [] + const updated: unknown[] = [] + const materialized = new Map() const recorded: unknown[] = [] const finished: unknown[] = [] const createOptions: AgentOptions[] = [] const sentMessages: unknown[] = [] + const resumedAgentIds: string[] = [] + const resumedOptions: Array | undefined> = [] + const kunContextSignals: AbortSignal[] = [] const run = input.run ?? fakeRun() const agent = { agentId: 'agent_1', @@ -92,8 +115,20 @@ function harness(input: { create: async (options) => { createOptions.push(options) return agent + }, + resume: async (agentId, options) => { + resumedAgentIds.push(agentId) + resumedOptions.push(options) + return agent } - } + }, + ...(input.sessionCoordinator && !input.omitLocalStore + ? { + JsonlLocalAgentStore: class { + constructor(readonly rootDir: string) {} + } as never + } + : {}) } const thread = { id: 'thread_1', @@ -132,7 +167,18 @@ function harness(input: { }] }, turns: { - applyItem: async (_threadId: string, item: unknown) => { applied.push(item) }, + applyItem: async (_threadId: string, item: TurnItem) => { + applied.push(item) + materialized.set(item.id, item) + }, + updateItem: async (_threadId: string, itemId: string, patch: Partial) => { + const existing = materialized.get(itemId) + if (!existing) return null + const item = { ...existing, ...patch } as TurnItem + updated.push(item) + materialized.set(itemId, item) + return item + }, finishTurn: async (value: unknown) => { finished.push(value) } }, events: { record: async (value: unknown) => { recorded.push(value) } }, @@ -143,15 +189,31 @@ function harness(input: { }, debugSink: input.debugSink, attachmentStore: input.attachmentStore, - turnLimits: input.turnLimits + turnLimits: input.turnLimits, + sessionCoordinator: input.sessionCoordinator, + contextProfile: input.contextProfile, + streamLimits: input.streamLimits, + ...(input.kunContext + ? { + loadKunTurnContext: async ({ signal }: { signal: AbortSignal }) => { + kunContextSignals.push(signal) + return input.kunContext! + } + } + : {}) } as unknown as CursorSdkRuntimeDeps return { runtime: new CursorSdkRuntime(deps), createOptions, applied, + updated, + materialized, recorded, finished, sentMessages, + kunContextSignals, + resumedAgentIds, + resumedOptions, agent } } @@ -166,7 +228,33 @@ describe('CursorSdkRuntime', () => { test('runs a complete local SDK turn with isolated settings and an SDK trace', async () => { const debugSink = new LlmDebugRecorder() - const h = harness({ debugSink }) + const h = harness({ + debugSink, + run: fakeRun({ + stream: [{ + type: 'tool_call', + agent_id: 'agent_1', + run_id: 'run_1', + call_id: 'call_1', + name: 'shell', + status: 'running', + args: { command: 'pwd' } + }, { + type: 'tool_call', + agent_id: 'agent_1', + run_id: 'run_1', + call_id: 'call_1', + name: 'shell', + status: 'completed', + result: { stdout: '/tmp' } + }, { + type: 'assistant', + agent_id: 'agent_1', + run_id: 'run_1', + message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }] } + }] + }) + }) await expect(h.runtime.runTurn( 'thread_1', 'turn_1', @@ -194,11 +282,373 @@ describe('CursorSdkRuntime', () => { expect(trace).toMatchObject({ transport: 'sdk', endpointFormat: 'cursor-sdk', - request: { method: 'SDK', url: 'cursor-sdk://local/agent' } + request: { method: 'SDK', url: 'cursor-sdk://local/agent' }, + delegated: { + providerKind: 'cursor-sdk', + phase: 'rebased', + contextManagement: 'sdk-managed', + nativeHistory: 'none' + }, + decoded: { + toolResults: [{ + callId: 'call_1', + toolName: 'shell', + output: '{"stdout":"/tmp"}', + isError: false + }] + } }) expect(JSON.stringify(trace)).not.toContain('cursor-secret') }) + test('materializes cumulative partial output before a stream failure', async () => { + const h = harness({ + streamLimits: { maxToolCalls: 1 }, + kunContext: { + instructionBlocks: [], + activeSkillIds: [], + tools: [], + customTools: {} + }, + run: fakeRun({ + stream: [{ + type: 'assistant', + agent_id: 'agent_1', + run_id: 'run_1', + message: { role: 'assistant', content: [{ type: 'text', text: 'first part' }] } + }, { + type: 'assistant', + agent_id: 'agent_1', + run_id: 'run_1', + message: { role: 'assistant', content: [{ type: 'text', text: ' and second part' }] } + }, { + type: 'tool_call', + agent_id: 'agent_1', + run_id: 'run_1', + call_id: 'call_1', + name: 'shell', + status: 'running', + args: { command: 'pwd' } + }, { + type: 'tool_call', + agent_id: 'agent_1', + run_id: 'run_1', + call_id: 'call_2', + name: 'shell', + status: 'running', + args: { command: 'ls' } + }] + }) + }) + + await expect(h.runtime.runTurn( + 'thread_1', + 'turn_1', + new AbortController().signal, + 'cursor-subscription' + )).resolves.toBe('failed') + + expect(h.applied).toContainEqual(expect.objectContaining({ + kind: 'assistant_text', + text: 'first part', + status: 'running' + })) + expect(h.updated).toContainEqual(expect.objectContaining({ + kind: 'assistant_text', + text: 'first part and second part', + status: 'running' + })) + expect([...h.materialized.values()]).toContainEqual(expect.objectContaining({ + kind: 'assistant_text', + text: 'first part and second part' + })) + expect(h.finished).toContainEqual(expect.objectContaining({ + status: 'failed', + code: 'cursor_sdk_stream_resource_limit' + })) + expect(h.kunContextSignals[0]?.aborted).toBe(true) + }) + + test('injects Kun instructions and custom tools into Cursor capabilities, context, and traces', async () => { + const debugSink = new LlmDebugRecorder() + const mcpExecute = vi.fn(async () => ({ + content: [{ type: 'text' as const, text: 'mcp result' }] + })) + const h = harness({ + debugSink, + contextProfile: () => ({ + contextWindowTokens: 100_000, + softThresholdTokens: 80_000, + hardThresholdTokens: 90_000 + }), + kunContext: { + instructionBlocks: ['Workspace AGENTS instructions', 'Active skill instructions'], + activeSkillIds: ['docs-skill'], + tools: [{ + name: 'mcp_call_tool', + description: 'Call an MCP tool', + inputSchema: { type: 'object' }, + providerId: 'mcp:facade', + providerKind: 'mcp' + }], + customTools: { + mcp_call_tool: { + description: 'Call an MCP tool', + inputSchema: { type: 'object' }, + execute: mcpExecute + } + } + } + }) + + await expect(h.runtime.runTurn( + 'thread_1', + 'turn_1', + new AbortController().signal, + 'cursor-subscription' + )).resolves.toBe('completed') + + expect(h.createOptions[0]?.local?.customTools).toHaveProperty('mcp_call_tool') + expect(String(h.sentMessages[0])).toContain('Kun system prompt') + expect(String(h.sentMessages[0])).toContain('Workspace AGENTS instructions') + expect(String(h.sentMessages[0])).toContain('Active skill instructions') + expect(h.recorded).toContainEqual(expect.objectContaining({ + kind: 'delegated_runtime', + capabilities: expect.objectContaining({ + kunTools: true, + externalApproval: true + }) + })) + expect(h.recorded).toContainEqual(expect.objectContaining({ + kind: 'context_snapshot', + toolCount: 1, + activeSkillIds: ['docs-skill'], + breakdown: expect.objectContaining({ tools: expect.any(Number) }) + })) + const trace = debugSink.snapshot()[0]?.exchanges[0] + expect(trace?.toolCatalog).toEqual([{ + name: 'mcp_call_tool', + providerId: 'mcp:facade', + providerKind: 'mcp' + }]) + const traceBody = JSON.parse(trace?.request.body.text ?? '{}') as Record + expect(traceBody).toMatchObject({ + instructions: expect.arrayContaining([ + 'Kun system prompt', + 'Workspace AGENTS instructions' + ]), + tools: [{ + name: 'mcp_call_tool', + description: 'Call an MCP tool' + }] + }) + expect(JSON.stringify(traceBody)).not.toContain('mcpExecute') + }) + + test('resumes a compatible persisted agent and sends only the current request', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-cursor-resume-')) + const coordinator = new DelegatedSessionCoordinator( + new FileDelegatedSessionBindingStore(root) + ) + const priorItems = [{ + id: 'user_old', + threadId: 'thread_1', + turnId: 'turn_old', + role: 'user', + status: 'completed', + createdAt: '2026-01-01T00:00:00.000Z', + kind: 'user_message', + text: 'portable old context' + }] as const + const route = { + providerKind: 'cursor-sdk' as const, + providerId: 'cursor-subscription', + credentialIdentity: delegatedCredentialIdentity({ + providerId: 'cursor-subscription', + credentialSecret: 'cursor-secret' + }), + workspace: '/tmp/cursor-workspace', + model: 'auto', + capabilityFingerprint: delegatedCapabilityFingerprint({ + systemPrompt: 'Kun system prompt', + threadPersona: '', + mode: 'agent', + sandbox: false, + settingSources: [], + capabilities: cursorSdkCapabilities() + }), + continuationMode: 'native' as const + } + const prepared = await coordinator.prepare({ + threadId: 'thread_1', + route, + priorItems: [] + }) + await coordinator.commit({ + preparation: prepared, + committedItems: priorItems as never, + lastCommittedTurnId: 'turn_old', + nativeSessionId: 'agent_persisted' + }) + expect((await coordinator.store.load('thread_1'))?.synchronizedHistoryDigest) + .toBe(delegatedHistoryDigest(priorItems as never)) + const h = harness({ + sessionCoordinator: coordinator, + thread: { + turns: [{ id: 'turn_1', model: 'auto', mode: 'agent' }] + }, + items: [ + ...priorItems, + { + id: 'user_1', + threadId: 'thread_1', + turnId: 'turn_1', + role: 'user', + status: 'completed', + createdAt: '2026-01-01T00:01:00.000Z', + kind: 'user_message', + text: 'current only' + } + ] + }) + + await expect(h.runtime.runTurn( + 'thread_1', + 'turn_1', + new AbortController().signal, + 'cursor-subscription' + )).resolves.toBe('completed') + + expect(h.resumedAgentIds).toEqual(['agent_persisted']) + expect( + (h.resumedOptions[0]?.local?.store as unknown as { rootDir?: string })?.rootDir + ).toContain('provider-state') + expect(String(h.sentMessages[0])).toContain('current only') + expect(String(h.sentMessages[0])).not.toContain('portable old context') + }) + + test('rotates native continuation when the bridged Kun tool catalog changes', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-cursor-tool-rotation-')) + const coordinator = new DelegatedSessionCoordinator( + new FileDelegatedSessionBindingStore(root) + ) + const priorItems = [{ + id: 'user_old', + threadId: 'thread_1', + turnId: 'turn_old', + role: 'user', + status: 'completed', + createdAt: '2026-01-01T00:00:00.000Z', + kind: 'user_message', + text: 'portable old context' + }] as const + const prepared = await coordinator.prepare({ + threadId: 'thread_1', + route: { + providerKind: 'cursor-sdk', + providerId: 'cursor-subscription', + credentialIdentity: delegatedCredentialIdentity({ + providerId: 'cursor-subscription', + credentialSecret: 'cursor-secret' + }), + workspace: '/tmp/cursor-workspace', + model: 'auto', + capabilityFingerprint: delegatedCapabilityFingerprint({ + systemPrompt: 'Kun system prompt', + threadPersona: '', + mode: 'agent', + sandbox: false, + settingSources: [], + capabilities: cursorSdkCapabilities(true), + instructions: [], + tools: [{ + name: 'old_mcp_tool', + description: 'Old MCP tool', + inputSchema: { type: 'object' }, + providerId: 'mcp:old', + providerKind: 'mcp' + }] + }), + continuationMode: 'native' + }, + priorItems: [] + }) + await coordinator.commit({ + preparation: prepared, + committedItems: priorItems as never, + lastCommittedTurnId: 'turn_old', + nativeSessionId: 'agent_old_catalog' + }) + const h = harness({ + sessionCoordinator: coordinator, + kunContext: { + instructionBlocks: [], + activeSkillIds: [], + tools: [{ + name: 'new_mcp_tool', + description: 'New MCP tool', + inputSchema: { type: 'object' }, + providerId: 'mcp:new', + providerKind: 'mcp' + }], + customTools: {} + }, + items: [ + ...priorItems, + { + id: 'user_1', + threadId: 'thread_1', + turnId: 'turn_1', + role: 'user', + status: 'completed', + createdAt: '2026-01-01T00:01:00.000Z', + kind: 'user_message', + text: 'current request' + } + ] + }) + + await expect(h.runtime.runTurn( + 'thread_1', + 'turn_1', + new AbortController().signal, + 'cursor-subscription' + )).resolves.toBe('completed') + + expect(h.resumedAgentIds).toEqual([]) + expect(h.createOptions).toHaveLength(1) + expect(String(h.sentMessages[0])).toContain('portable old context') + expect(h.recorded).toContainEqual(expect.objectContaining({ + kind: 'delegated_runtime', + phase: 'rebased', + reason: 'capabilities_changed' + })) + }) + + test('fails closed when an SDK downgrade removes the isolated local store', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-cursor-store-missing-')) + const coordinator = new DelegatedSessionCoordinator( + new FileDelegatedSessionBindingStore(root) + ) + const h = harness({ + sessionCoordinator: coordinator, + omitLocalStore: true + }) + await expect(h.runtime.runTurn( + 'thread_1', + 'turn_1', + new AbortController().signal, + 'cursor-subscription' + )).resolves.toBe('failed') + expect(h.createOptions).toEqual([]) + expect(h.recorded).toContainEqual(expect.objectContaining({ + kind: 'delegated_runtime', + phase: 'portable', + reason: 'capabilities_changed', + capabilities: expect.objectContaining({ nativeResume: false }) + })) + }) + test('uses plan mode and sandbox when Kun cannot auto-approve mutation', () => { expect(cursorAgentExecutionOptions({ workspace: '/tmp/work', @@ -298,6 +748,10 @@ describe('CursorSdkRuntime', () => { }) test('cancels an active SDK run when the Kun turn aborts', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-cursor-abort-')) + const coordinator = new DelegatedSessionCoordinator( + new FileDelegatedSessionBindingStore(root) + ) let release!: () => void const blocked = new Promise((resolve) => { release = resolve }) const cancel = vi.fn(async () => { release() }) @@ -306,7 +760,7 @@ describe('CursorSdkRuntime', () => { await blocked yield* [] })() - const h = harness({ run }) + const h = harness({ run, sessionCoordinator: coordinator }) const controller = new AbortController() const outcome = h.runtime.runTurn('thread_1', 'turn_1', controller.signal, 'cursor-subscription') await vi.waitFor(() => expect(h.createOptions).toHaveLength(1)) @@ -314,6 +768,7 @@ describe('CursorSdkRuntime', () => { await expect(outcome).resolves.toBe('aborted') expect(cancel).toHaveBeenCalled() expect(h.finished).toContainEqual(expect.objectContaining({ status: 'aborted' })) + expect(await coordinator.store.load('thread_1')).toBeNull() }) test('cancels and reports a stable failure when wall time expires', async () => { diff --git a/kun/src/runtime/cursor/cursor-sdk-runtime.ts b/kun/src/runtime/cursor/cursor-sdk-runtime.ts index 78297d06a..d54db8c54 100644 --- a/kun/src/runtime/cursor/cursor-sdk-runtime.ts +++ b/kun/src/runtime/cursor/cursor-sdk-runtime.ts @@ -1,8 +1,10 @@ import type { AgentOptions, + LocalAgentStore, Run, RunResult, SDKAgent, + SDKCustomTool, SDKImage, SDKMessage, SDKUserMessage, @@ -14,7 +16,10 @@ import { MAX_TURN_ATTACHMENT_BYTES, MAX_TURN_ATTACHMENT_IDS } from '../../contracts/attachments.js' -import type { ModelRequestTraceRecord } from '../../contracts/model-request-trace.js' +import type { + ModelRequestTraceDelegated, + ModelRequestTraceRecord +} from '../../contracts/model-request-trace.js' import type { TurnItem } from '../../contracts/items.js' import type { UsageSnapshot } from '../../contracts/usage.js' import { userMessageTextWithComposerContexts } from '../../domain/composer-context.js' @@ -32,13 +37,24 @@ import { composeSdkPromptText, DEFAULT_SDK_HISTORY_TRANSCRIPT_MAX_BYTES } from '../agent-sdk/sdk-context-assembler.js' -import type { DelegatedTurnRuntime } from '../delegated-turn-runtime.js' +import type { + DelegatedRuntimeCapabilities, + DelegatedTurnRuntime +} from '../delegated-turn-runtime.js' +import { + delegatedCapabilityFingerprint, + delegatedCredentialIdentity, + priorItemsForDelegatedTurn, + type DelegatedSessionCoordinator, + type DelegatedSessionPreparation +} from '../delegated-session-binding.js' import { CursorSdkEventMapper, CursorSdkResourceLimitError, mapCursorUsage, type CursorSdkStreamLimits } from './cursor-sdk-event-mapper.js' +import type { CursorBridgeTool } from './cursor-sdk-tool-bridge.js' const DEFAULT_CURSOR_MODEL = 'auto' const MAX_CURSOR_ERROR_LENGTH = 2_000 @@ -46,7 +62,9 @@ const MAX_CURSOR_ERROR_LENGTH = 2_000 export interface CursorSdkApi { Agent: { create(options: AgentOptions): Promise + resume(agentId: string, options?: Partial): Promise } + JsonlLocalAgentStore?: new (rootDir: string) => LocalAgentStore } export interface CursorSdkRuntimeDeps { @@ -68,6 +86,25 @@ export interface CursorSdkRuntimeDeps { loadSdk?: () => Promise /** Delegated read-only children must deny mutation regardless of parent defaults. */ enforceReadOnly?: boolean + sessionCoordinator?: DelegatedSessionCoordinator + contextProfile?: (model: string) => { + contextWindowTokens: number + softThresholdTokens: number + hardThresholdTokens: number + } + loadKunTurnContext?: (input: { + threadId: string + turnId: string + userText: string + signal: AbortSignal + }) => Promise +} + +export type CursorKunTurnContext = { + instructionBlocks: string[] + activeSkillIds: string[] + tools: CursorBridgeTool[] + customTools: Record } class CursorTurnInterruptedError extends Error { @@ -216,11 +253,44 @@ export class CursorSdkRuntime implements DelegatedTurnRuntime { return !providerId || !this.deps.providerConfigs[providerId] } + capabilities(providerId: string | undefined): DelegatedRuntimeCapabilities | undefined { + if (!this.handlesProvider(providerId)) return undefined + return cursorSdkCapabilities(Boolean(this.deps.loadKunTurnContext)) + } + async runTurn( threadId: string, turnId: string, signal: AbortSignal, providerId?: string + ): Promise<'completed' | 'failed' | 'aborted'> { + const runtimeController = new AbortController() + const abortRuntime = (): void => runtimeController.abort() + signal.addEventListener('abort', abortRuntime, { once: true }) + if (signal.aborted) abortRuntime() + const execute = () => this.runTurnOwned( + threadId, + turnId, + runtimeController.signal, + providerId, + abortRuntime + ) + try { + return await (this.deps.sessionCoordinator + ? this.deps.sessionCoordinator.runExclusive(threadId, execute) + : execute()) + } finally { + abortRuntime() + signal.removeEventListener('abort', abortRuntime) + } + } + + private async runTurnOwned( + threadId: string, + turnId: string, + signal: AbortSignal, + providerId: string | undefined, + abortRuntime: () => void ): Promise<'completed' | 'failed' | 'aborted'> { const thread = await this.deps.threadStore.get(threadId) const turn = thread?.turns.find((candidate) => candidate.id === turnId) @@ -270,20 +340,55 @@ export class CursorSdkRuntime implements DelegatedTurnRuntime { return 'aborted' } - const prompt = composeSdkPromptText({ - historyTranscript: buildHistoryTranscript( - items, - turnId, - DEFAULT_SDK_HISTORY_TRANSCRIPT_MAX_BYTES - ), - userText: userMessageTextWithComposerContexts(userItem), - instructionBlocks: [ - this.deps.systemPrompt?.trim(), - thread.systemPrompt?.trim() - ].filter((value, index, all): value is string => - Boolean(value) && all.indexOf(value) === index - ) - }) + const historyTranscript = buildHistoryTranscript( + items, + turnId, + DEFAULT_SDK_HISTORY_TRANSCRIPT_MAX_BYTES + ) + const userText = userMessageTextWithComposerContexts(userItem) + let kunContext: CursorKunTurnContext = { + instructionBlocks: [], + activeSkillIds: [], + tools: [], + customTools: {} + } + if (this.deps.loadKunTurnContext) { + try { + kunContext = await this.deps.loadKunTurnContext({ + threadId, + turnId, + userText, + signal + }) + } catch (error) { + abortRuntime() + const message = sanitizeCursorSdkError(error, apiKey) + await this.deps.events.record({ + kind: 'error', + threadId, + turnId, + message, + code: 'cursor_sdk_context_failed', + severity: 'error' + }) + await this.deps.turns.finishTurn({ + threadId, + turnId, + status: 'failed', + error: message, + code: 'cursor_sdk_context_failed', + severity: 'error' + }) + return 'failed' + } + } + const instructionBlocks = [ + this.deps.systemPrompt?.trim(), + thread.systemPrompt?.trim(), + ...kunContext.instructionBlocks + ].filter((value, index, all): value is string => + Boolean(value) && all.indexOf(value) === index + ) const model = normalizeCursorModel(turn.model || thread.model || this.deps.defaultModel) const attachmentIds = userItem.attachmentIds ?? [] const resolvedImages = await resolveCursorSdkImages({ @@ -292,11 +397,9 @@ export class CursorSdkRuntime implements DelegatedTurnRuntime { threadId, workspace: thread.workspace }) - const sdkMessage: string | SDKUserMessage = resolvedImages.images.length > 0 - ? { text: prompt, images: resolvedImages.images } - : prompt const planMode = this.deps.enforceReadOnly === true || (turn.mode ?? thread.mode) === 'plan' - const options = cursorAgentExecutionOptions({ + let capabilities = cursorSdkCapabilities(Boolean(this.deps.loadKunTurnContext)) + let options = cursorAgentExecutionOptions({ workspace: thread.workspace, apiKey, model, @@ -306,6 +409,102 @@ export class CursorSdkRuntime implements DelegatedTurnRuntime { sandboxMode: thread.sandboxMode, enforceReadOnly: this.deps.enforceReadOnly }) + if (Object.keys(kunContext.customTools).length > 0) { + options = { + ...options, + local: { + ...options.local, + customTools: kunContext.customTools + } + } + } + let preparation: DelegatedSessionPreparation | undefined + if (this.deps.sessionCoordinator) { + preparation = await this.deps.sessionCoordinator.prepare({ + threadId, + route: { + providerKind: 'cursor-sdk', + providerId: resolvedProviderId, + credentialIdentity: delegatedCredentialIdentity({ + providerId: resolvedProviderId, + accountId: turn.accountId || thread.accountId, + credentialSourceId: provider?.credentialSourceId, + credentialSecret: apiKey + }), + workspace: thread.workspace, + model, + capabilityFingerprint: delegatedCapabilityFingerprint({ + systemPrompt: this.deps.systemPrompt?.trim() || '', + threadPersona: thread.systemPrompt?.trim() || '', + mode: options.mode, + sandbox: options.local?.sandboxOptions?.enabled !== false, + settingSources: options.local?.settingSources ?? [], + capabilities, + ...(this.deps.loadKunTurnContext + ? { + instructions: kunContext.instructionBlocks, + tools: kunContext.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + providerId: tool.providerId, + providerKind: tool.providerKind + })) + } + : {}) + }), + continuationMode: 'native' + }, + priorItems: priorItemsForDelegatedTurn(items, turnId) + }) + } + const buildPrompt = (includeHistory: boolean): string => composeSdkPromptText({ + ...(includeHistory && historyTranscript ? { historyTranscript } : {}), + userText, + instructionBlocks + }) + let prompt = buildPrompt(!preparation?.resumed) + let sdkMessage: string | SDKUserMessage = resolvedImages.images.length > 0 + ? { text: prompt, images: resolvedImages.images } + : prompt + await this.deps.events.record({ + kind: 'delegated_runtime', + threadId, + turnId, + providerKind: 'cursor-sdk', + providerId: resolvedProviderId, + phase: preparation?.resumed ? 'resumed' : 'rebased', + ...(preparation?.rebaseReason ? { reason: preparation.rebaseReason } : {}), + capabilities + }) + const contextProfile = this.deps.contextProfile?.(model) + const recordContextSnapshot = async (resumed: boolean): Promise => { + if (!contextProfile) return + const system = estimateDelegatedTokens(instructionBlocks.join('\n')) + const messages = estimateDelegatedTokens([ + resumed ? '' : historyTranscript, + userText + ].join('\n')) + const tools = estimateDelegatedTokens(JSON.stringify(kunContext.tools)) + const skills = estimateDelegatedTokens(kunContext.activeSkillIds.join('\n')) + const other = resolvedImages.images.length * 1_024 + await this.deps.events.record({ + kind: 'context_snapshot', + threadId, + turnId, + model, + providerId: resolvedProviderId, + stepIndex: 0, + ...contextProfile, + estimatedInputTokens: system + skills + tools + messages + other, + breakdown: { tools, system, skills, messages, other }, + toolCount: kunContext.tools.length, + activeSkillIds: kunContext.activeSkillIds, + contextManagement: 'sdk-managed', + nativeHistory: resumed ? 'unknown' : 'none' + }) + } + await recordContextSnapshot(preparation?.resumed === true) const limits = normalizeTurnLimits(this.deps.turnLimits) const mapper = new CursorSdkEventMapper({ threadId, @@ -315,16 +514,8 @@ export class CursorSdkRuntime implements DelegatedTurnRuntime { nextId: (prefix) => this.deps.ids.next(prefix), limits: this.deps.streamLimits }) - let trace = startCursorTrace(this.deps.debugSink, { - threadId, - turnId, - provider: resolvedProviderId, - model, - prompt, - images: resolvedImages.summaries, - mode: options.mode ?? 'plan', - sandboxEnabled: options.local?.sandboxOptions?.enabled !== false - }) + const materializedOutputItemIds = new Set() + let trace: CursorTrace | undefined let agent: SDKAgent | undefined let run: Run | undefined let timedOut = false @@ -354,7 +545,94 @@ export class CursorSdkRuntime implements DelegatedTurnRuntime { import('@cursor/sdk').then((module) => module as CursorSdkApi), interrupted ]) - agent = await Promise.race([sdk.Agent.create(options), interrupted]) + const attachIsolatedStore = (): void => { + if (!this.deps.sessionCoordinator || !sdk.JsonlLocalAgentStore) return + const store = new sdk.JsonlLocalAgentStore( + this.deps.sessionCoordinator.store.providerStateDir('cursor-sdk', threadId) + ) + options = { + ...options, + local: { ...options.local, store } + } + } + if (this.deps.sessionCoordinator && !sdk.JsonlLocalAgentStore) { + if (preparation?.resumed) { + preparation = await this.deps.sessionCoordinator.rejectResume(preparation) + } + capabilities = { ...capabilities, nativeResume: false } + await this.deps.events.record({ + kind: 'delegated_runtime', + threadId, + turnId, + providerKind: 'cursor-sdk', + providerId: resolvedProviderId, + phase: 'portable', + reason: 'capabilities_changed', + capabilities + }) + throw new Error( + 'Cursor SDK configuration does not expose the isolated local agent store required for durable sessions' + ) + } + attachIsolatedStore() + if (preparation?.resumed && preparation.nativeSessionId) { + try { + agent = await Promise.race([ + sdk.Agent.resume(preparation.nativeSessionId, options), + interrupted + ]) + } catch (error) { + if (error instanceof CursorTurnInterruptedError) throw error + preparation = this.deps.sessionCoordinator + ? await this.deps.sessionCoordinator.rejectResume(preparation) + : { + ...preparation, + generation: preparation.generation + 1, + nativeSessionId: undefined, + resumed: false, + rebaseReason: 'native_state_unavailable' + } + attachIsolatedStore() + prompt = buildPrompt(true) + sdkMessage = resolvedImages.images.length > 0 + ? { text: prompt, images: resolvedImages.images } + : prompt + await this.deps.events.record({ + kind: 'delegated_runtime', + threadId, + turnId, + providerKind: 'cursor-sdk', + providerId: resolvedProviderId, + phase: 'rebased', + reason: 'native_state_unavailable', + capabilities + }) + await recordContextSnapshot(false) + agent = await Promise.race([sdk.Agent.create(options), interrupted]) + } + } else { + agent = await Promise.race([sdk.Agent.create(options), interrupted]) + } + trace = startCursorTrace(this.deps.debugSink, { + threadId, + turnId, + provider: resolvedProviderId, + model, + prompt, + instructions: instructionBlocks, + tools: kunContext.tools, + images: resolvedImages.summaries, + mode: options.mode ?? 'plan', + sandboxEnabled: options.local?.sandboxOptions?.enabled !== false, + delegated: { + providerKind: 'cursor-sdk', + phase: preparation?.resumed ? 'resumed' : 'rebased', + ...(preparation?.rebaseReason ? { reason: preparation.rebaseReason } : {}), + contextManagement: 'sdk-managed', + nativeHistory: preparation?.resumed ? 'unknown' : 'none', + capabilities + } + }) run = await Promise.race([ agent.send(sdkMessage, { mode: options.mode }), interrupted @@ -365,7 +643,7 @@ export class CursorSdkRuntime implements DelegatedTurnRuntime { for (;;) { const next = await Promise.race([iterator.next(), interrupted]) if (next.done) break - await this.consumeMessage(mapper, next.value, trace) + await this.consumeMessage(mapper, next.value, trace, materializedOutputItemIds) } } const result = await Promise.race([run.wait(), interrupted]) @@ -388,14 +666,30 @@ export class CursorSdkRuntime implements DelegatedTurnRuntime { await finishCursorTrace(trace, { kind: 'completed' }) trace = undefined await this.deps.turns.finishTurn({ threadId, turnId, status: 'completed' }) + if (preparation && this.deps.sessionCoordinator) { + try { + await this.deps.sessionCoordinator.commit({ + preparation, + committedItems: await this.deps.sessionStore.loadItems(threadId), + lastCommittedTurnId: turnId, + nativeSessionId: agent.agentId + }) + } catch { + // The canonical Kun turn is already durable. A checkpoint write + // failure simply forces a portable rebase on the next turn. + } + } return 'completed' } catch (error) { + const abortedBeforeFailure = signal.aborted + abortRuntime() + cancelRun() const safeTraceError = new Error(sanitizeCursorSdkError(error, apiKey)) safeTraceError.name = error instanceof Error ? error.name : 'CursorSdkError' await finishCursorTrace(trace, { kind: 'error', error: safeTraceError }) trace = undefined if ( - signal.aborted + abortedBeforeFailure || error instanceof CursorTurnInterruptedError && error.reason === 'aborted' ) { await this.deps.turns.finishTurn({ threadId, turnId, status: 'aborted' }) @@ -435,10 +729,33 @@ export class CursorSdkRuntime implements DelegatedTurnRuntime { private async consumeMessage( mapper: CursorSdkEventMapper, message: SDKMessage, - trace: CursorTrace | undefined + trace: CursorTrace | undefined, + materializedOutputItemIds: Set ): Promise { captureCursorMessage(trace, message) - for (const draft of mapper.map(message)) { + const drafts = mapper.map(message) + const outputItem = message.type === 'assistant' + ? mapper.runningTextItem + : message.type === 'thinking' + ? mapper.runningReasoningItem + : undefined + if (outputItem) { + if (materializedOutputItemIds.has(outputItem.id)) { + const updated = await this.deps.turns.updateItem( + outputItem.threadId, + outputItem.id, + outputItem + ) + if (!updated) { + await this.deps.turns.applyItem(outputItem.threadId, outputItem) + } + } else { + await this.deps.turns.applyItem(outputItem.threadId, outputItem) + materializedOutputItemIds.add(outputItem.id) + } + } + for (const draft of drafts) { + captureCursorTraceDraft(trace, draft) await this.emitDraft(draft.threadId, draft) } } @@ -458,6 +775,50 @@ export class CursorSdkRuntime implements DelegatedTurnRuntime { } } +function captureCursorTraceDraft( + trace: CursorTrace | undefined, + draft: RuntimeEventDraft +): void { + if (!trace?.sink.captureToolResult) return + const item = itemOf(draft) + if (draft.kind !== 'tool_call_finished' || item?.kind !== 'tool_result') return + try { + trace.sink.captureToolResult(trace.round, { + callId: item.callId, + toolName: item.toolName, + output: traceOutputText(item.output), + isError: item.isError + }) + } catch { + warnCursorTraceFailure() + } +} + +function traceOutputText(value: unknown): string { + if (typeof value === 'string') return value + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +function estimateDelegatedTokens(text: string): number { + return text ? Math.ceil(Buffer.byteLength(text, 'utf8') / 4) : 0 +} + +export function cursorSdkCapabilities(kunTools = false): DelegatedRuntimeCapabilities { + return { + nativeResume: true, + structuredStreaming: true, + kunTools, + externalApproval: kunTools, + liveSteering: false, + nativeContextTelemetry: false, + fork: false + } +} + function itemOf(draft: RuntimeEventDraft): TurnItem | undefined { return 'item' in draft ? draft.item as TurnItem : undefined } @@ -482,9 +843,12 @@ function startCursorTrace( provider: string model: string prompt: string + instructions: readonly string[] + tools: readonly CursorBridgeTool[] images: readonly CursorSdkImageSummary[] mode: 'agent' | 'plan' sandboxEnabled: boolean + delegated: ModelRequestTraceDelegated } ): CursorTrace | undefined { if (!sink?.beginSdkInvocation) return undefined @@ -494,21 +858,33 @@ function startCursorTrace( threadId: input.threadId, turnId: input.turnId, provider: input.provider, - model: input.model + model: input.model, + toolCatalog: input.tools.map((tool) => ({ + name: tool.name, + providerKind: tool.providerKind, + providerId: tool.providerId + })) }) const record = sink.beginSdkInvocation(round, { endpointFormat: 'cursor-sdk', target: 'cursor-sdk://local/agent', bodyText: JSON.stringify({ model: input.model, + instructions: input.instructions, input: input.prompt, + tools: input.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema + })), attachments: { count: input.images.length, images: input.images }, mode: input.mode, sandbox: input.sandboxEnabled - }) + }), + delegated: input.delegated }) return { sink, round, record } } catch { diff --git a/kun/src/runtime/cursor/cursor-sdk-tool-bridge.test.ts b/kun/src/runtime/cursor/cursor-sdk-tool-bridge.test.ts new file mode 100644 index 000000000..7a6732e13 --- /dev/null +++ b/kun/src/runtime/cursor/cursor-sdk-tool-bridge.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test, vi } from 'vitest' +import { + buildCursorCustomTools, + selectCursorBridgeTools, + type CursorBridgeTool +} from './cursor-sdk-tool-bridge.js' + +const tools: CursorBridgeTool[] = [{ + name: 'mcp_call_tool', + description: 'Call an MCP tool', + inputSchema: { + type: 'object', + properties: { serverId: { type: 'string' } }, + required: ['serverId'] + }, + providerId: 'mcp:facade', + providerKind: 'mcp' +}, { + name: 'extension_render', + description: 'Render through an extension', + inputSchema: { type: 'object' }, + providerId: 'extension:render', + providerKind: 'extension' +}, { + name: 'echo', + description: 'Internal echo', + inputSchema: { type: 'object' }, + providerId: 'builtin', + providerKind: 'built-in' +}] + +describe('Cursor SDK Kun custom-tool bridge', () => { + test('keeps Kun and provider provenance while excluding internal-only tools', () => { + expect(selectCursorBridgeTools(tools).map((tool) => [ + tool.name, + tool.providerId, + tool.providerKind + ])).toEqual([ + ['mcp_call_tool', 'mcp:facade', 'mcp'], + ['extension_render', 'extension:render', 'extension'] + ]) + }) + + test('maps Cursor callbacks to Kun execution and preserves call identity', async () => { + const execute = vi.fn(async () => ({ + output: { ok: true, value: 42 } + })) + const customTools = buildCursorCustomTools(tools, execute) + + await expect(customTools.mcp_call_tool?.execute( + { serverId: 'docs' }, + { toolCallId: 'cursor-call-1' } + )).resolves.toEqual({ + content: [{ + type: 'text', + text: JSON.stringify({ ok: true, value: 42 }, null, 2) + }] + }) + expect(execute).toHaveBeenCalledWith( + 'mcp_call_tool', + { serverId: 'docs' }, + 'cursor-call-1' + ) + expect(customTools.mcp_call_tool?.inputSchema).toMatchObject({ + required: ['serverId'] + }) + expect(customTools.echo).toBeUndefined() + }) + + test('returns callback failures to Cursor as tool errors', async () => { + const customTools = buildCursorCustomTools(tools, async () => { + throw new Error('MCP disconnected') + }) + await expect(customTools.mcp_call_tool?.execute({}, {})).resolves.toEqual({ + content: [{ + type: 'text', + text: 'Kun tool "mcp_call_tool" failed: MCP disconnected' + }], + isError: true + }) + }) +}) diff --git a/kun/src/runtime/cursor/cursor-sdk-tool-bridge.ts b/kun/src/runtime/cursor/cursor-sdk-tool-bridge.ts new file mode 100644 index 000000000..47861fc98 --- /dev/null +++ b/kun/src/runtime/cursor/cursor-sdk-tool-bridge.ts @@ -0,0 +1,67 @@ +import type { + SDKCustomTool, + SDKCustomToolContext, + SDKJsonValue +} from '@cursor/sdk' +import type { CapabilityToolSpec } from '../../adapters/tool/capability-registry.js' +import { + mapKunResultToSdkContent, + type KunToolResult +} from '../agent-sdk/sdk-tool-bridge.js' + +export type CursorBridgeTool = Pick< + CapabilityToolSpec, + 'name' | 'description' | 'inputSchema' | 'providerId' | 'providerKind' +> + +export type CursorKunToolExecutor = ( + toolName: string, + args: Record, + toolCallId?: string +) => Promise + +const CURSOR_BRIDGE_EXCLUDED_TOOL_NAMES = new Set(['echo']) + +export function selectCursorBridgeTools( + tools: readonly CursorBridgeTool[] +): CursorBridgeTool[] { + const seen = new Set() + return tools.filter((tool) => { + const name = tool.name.trim() + if (!name || seen.has(name) || CURSOR_BRIDGE_EXCLUDED_TOOL_NAMES.has(name)) return false + seen.add(name) + return true + }) +} + +export function buildCursorCustomTools( + tools: readonly CursorBridgeTool[], + execute: CursorKunToolExecutor +): Record { + const customTools: Record = {} + for (const tool of selectCursorBridgeTools(tools)) { + customTools[tool.name] = { + description: tool.description, + inputSchema: tool.inputSchema as Record, + execute: async ( + args: Record, + context: SDKCustomToolContext + ) => { + try { + return mapKunResultToSdkContent(await execute(tool.name, args, context.toolCallId)) + } catch (error) { + return { + content: [{ + type: 'text', + text: `Kun tool "${tool.name}" failed: ${ + error instanceof Error ? error.message : String(error) + }` + }], + isError: true + } + } + } + } + } + return customTools +} diff --git a/kun/src/runtime/delegated-session-binding.test.ts b/kun/src/runtime/delegated-session-binding.test.ts new file mode 100644 index 000000000..2c9bcecbe --- /dev/null +++ b/kun/src/runtime/delegated-session-binding.test.ts @@ -0,0 +1,336 @@ +import { access, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, test, vi } from 'vitest' +import type { TurnItem } from '../contracts/items.js' +import { + DelegatedSessionCoordinator, + FileDelegatedSessionBindingStore, + delegatedCapabilityFingerprint, + delegatedCredentialIdentity, + delegatedHistoryDigest, + type DelegatedSessionRoute +} from './delegated-session-binding.js' + +function user(turnId: string, text: string): Extract { + return { + id: `item_${turnId}`, + threadId: 'thread_1', + turnId, + role: 'user', + kind: 'user_message', + status: 'completed', + createdAt: '2026-01-01T00:00:00.000Z', + text + } +} + +function route(overrides: Partial = {}): DelegatedSessionRoute { + return { + providerKind: 'cursor-sdk', + providerId: 'cursor-subscription', + credentialIdentity: delegatedCredentialIdentity({ + providerId: 'cursor-subscription', + accountId: 'account-1' + }), + workspace: '/tmp/work', + model: 'auto', + capabilityFingerprint: delegatedCapabilityFingerprint({ + policy: 'auto', + tools: [] + }), + continuationMode: 'native', + ...overrides + } +} + +describe('DelegatedSessionCoordinator', () => { + test('persists a secret-free binding and resumes it after restart', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-delegated-')) + const store = new FileDelegatedSessionBindingStore(root) + const first = new DelegatedSessionCoordinator(store, () => '2026-01-01T00:00:00.000Z') + const prior = [user('turn_1', 'hello')] + const prepared = await first.prepare({ threadId: 'thread_1', route: route(), priorItems: [] }) + await first.commit({ + preparation: prepared, + committedItems: prior, + lastCommittedTurnId: 'turn_1', + nativeSessionId: 'agent_1' + }) + + const restarted = new DelegatedSessionCoordinator( + new FileDelegatedSessionBindingStore(root) + ) + const next = await restarted.prepare({ + threadId: 'thread_1', + route: route(), + priorItems: prior + }) + expect(next).toMatchObject({ + generation: 1, + resumed: true, + nativeSessionId: 'agent_1' + }) + const serialized = JSON.stringify(await store.load('thread_1')) + expect(serialized).not.toContain('hello') + expect(serialized).not.toContain('apiKey') + expect(serialized).not.toContain('account-1') + }) + + test('rebases on route, capability, or canonical-history mismatch', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-delegated-')) + const coordinator = new DelegatedSessionCoordinator( + new FileDelegatedSessionBindingStore(root) + ) + const prepared = await coordinator.prepare({ + threadId: 'thread_1', + route: route(), + priorItems: [] + }) + await coordinator.commit({ + preparation: prepared, + committedItems: [user('turn_1', 'first')], + lastCommittedTurnId: 'turn_1', + nativeSessionId: 'agent_1' + }) + + await expect(coordinator.prepare({ + threadId: 'thread_1', + route: route({ + credentialIdentity: delegatedCredentialIdentity({ + providerId: 'cursor-subscription', + accountId: 'account-2' + }) + }), + priorItems: [user('turn_1', 'first')] + })).resolves.toMatchObject({ resumed: false, rebaseReason: 'route_changed' }) + await expect(coordinator.prepare({ + threadId: 'thread_1', + route: route({ capabilityFingerprint: delegatedCapabilityFingerprint('changed') }), + priorItems: [user('turn_1', 'first')] + })).resolves.toMatchObject({ resumed: false, rebaseReason: 'capabilities_changed' }) + await expect(coordinator.prepare({ + threadId: 'thread_1', + route: route(), + priorItems: [user('turn_1', 'changed')] + })).resolves.toMatchObject({ resumed: false, rebaseReason: 'history_changed' }) + }) + + test('does not advance a binding until commit and serializes one thread', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-delegated-')) + const coordinator = new DelegatedSessionCoordinator( + new FileDelegatedSessionBindingStore(root) + ) + const order: string[] = [] + let release!: () => void + const blocked = new Promise((resolve) => { release = resolve }) + const first = coordinator.runExclusive('thread_1', async () => { + order.push('first-start') + await blocked + order.push('first-end') + }) + const second = coordinator.runExclusive('thread_1', async () => { + order.push('second') + }) + await vi.waitFor(() => expect(order).toEqual(['first-start'])) + release() + await Promise.all([first, second]) + expect(order).toEqual(['first-start', 'first-end', 'second']) + expect(await coordinator.store.load('thread_1')).toBeNull() + }) + + test('clears stale provider checkpoints when rebasing or rejecting resume', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-delegated-')) + const store = new FileDelegatedSessionBindingStore(root) + const coordinator = new DelegatedSessionCoordinator(store) + const prepared = await coordinator.prepare({ + threadId: 'thread_1', + route: route(), + priorItems: [] + }) + await coordinator.commit({ + preparation: prepared, + committedItems: [user('turn_1', 'first')], + lastCommittedTurnId: 'turn_1', + nativeSessionId: 'agent_1' + }) + const stateDir = store.providerStateDir('cursor-sdk', 'thread_1') + await mkdir(stateDir, { recursive: true }) + const checkpoint = join(stateDir, 'checkpoint') + await writeFile(checkpoint, 'stale') + + const resumed = await coordinator.prepare({ + threadId: 'thread_1', + route: route(), + priorItems: [user('turn_1', 'first')] + }) + expect(resumed.resumed).toBe(true) + const rejected = await coordinator.rejectResume(resumed) + expect(rejected).toMatchObject({ + generation: 2, + resumed: false, + rebaseReason: 'native_state_unavailable' + }) + await expect(access(checkpoint)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + test('clears both old and new provider state after a provider switch', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-delegated-')) + const store = new FileDelegatedSessionBindingStore(root) + const coordinator = new DelegatedSessionCoordinator(store) + const prepared = await coordinator.prepare({ + threadId: 'thread_1', + route: route({ providerKind: 'agent-sdk' }), + priorItems: [] + }) + await coordinator.commit({ + preparation: prepared, + committedItems: [user('turn_1', 'first')], + lastCommittedTurnId: 'turn_1', + nativeSessionId: 'session_1' + }) + const oldCheckpoint = join( + store.providerStateDir('agent-sdk', 'thread_1'), + 'checkpoint' + ) + const newCheckpoint = join( + store.providerStateDir('cursor-sdk', 'thread_1'), + 'checkpoint' + ) + await mkdir(join(oldCheckpoint, '..'), { recursive: true }) + await mkdir(join(newCheckpoint, '..'), { recursive: true }) + await writeFile(oldCheckpoint, 'old') + await writeFile(newCheckpoint, 'new') + + await coordinator.prepare({ + threadId: 'thread_1', + route: route({ providerKind: 'cursor-sdk' }), + priorItems: [user('turn_1', 'first')] + }) + + await expect(access(oldCheckpoint)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(access(newCheckpoint)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + test('removes malformed records and writes complete atomic JSON', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-delegated-')) + const store = new FileDelegatedSessionBindingStore(root) + const coordinator = new DelegatedSessionCoordinator(store) + const prepared = await coordinator.prepare({ + threadId: 'thread_1', + route: route(), + priorItems: [] + }) + const saved = await coordinator.commit({ + preparation: prepared, + committedItems: [user('turn_1', 'one')], + lastCommittedTurnId: 'turn_1', + nativeSessionId: 'agent_1' + }) + const bindingDir = join(root, 'bindings') + const files = (await import('node:fs/promises')).readdir(bindingDir) + const [name] = await files + expect(name).toMatch(/\.json$/) + expect(JSON.parse(await readFile(join(bindingDir, name!), 'utf8'))).toEqual(saved) + await writeFile(join(bindingDir, name!), '{broken', 'utf8') + await expect(store.load('thread_1')).resolves.toBeNull() + }) + + test('history digest ignores timestamps but changes after compaction or content changes', () => { + const original = user('turn_1', 'same') + expect(delegatedHistoryDigest([original])).toBe(delegatedHistoryDigest([ + { ...original, createdAt: '2027-01-01T00:00:00.000Z' } + ])) + expect(delegatedHistoryDigest([original])).not.toBe(delegatedHistoryDigest([ + { ...original, text: 'different' } + ])) + const compacted: TurnItem = { + id: 'compact_1', + threadId: 'thread_1', + turnId: 'turn_2', + role: 'system', + status: 'completed', + createdAt: '2026-01-01T00:01:00.000Z', + kind: 'compaction', + summary: 'new portable baseline', + replacedTokens: 100, + pinnedConstraints: [] + } + expect(delegatedHistoryDigest([original])).not.toBe( + delegatedHistoryDigest([original, compacted]) + ) + }) + + test('credential identity rotates on secret changes without persisting the secret', () => { + const first = delegatedCredentialIdentity({ + providerId: 'cursor-subscription', + credentialSecret: 'cursor-secret-one' + }) + const second = delegatedCredentialIdentity({ + providerId: 'cursor-subscription', + credentialSecret: 'cursor-secret-two' + }) + expect(first).not.toBe(second) + expect(first).not.toContain('cursor-secret-one') + }) + + test('keeps an aligned portable generation without claiming native resume failed', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-delegated-')) + const coordinator = new DelegatedSessionCoordinator( + new FileDelegatedSessionBindingStore(root) + ) + const portableRoute = route({ + providerKind: 'antigravity-cli', + continuationMode: 'portable' + }) + const prepared = await coordinator.prepare({ + threadId: 'thread_1', + route: portableRoute, + priorItems: [] + }) + const committedItems = [user('turn_1', 'portable')] + await coordinator.commit({ + preparation: prepared, + committedItems, + lastCommittedTurnId: 'turn_1' + }) + await expect(coordinator.prepare({ + threadId: 'thread_1', + route: portableRoute, + priorItems: committedItems + })).resolves.toMatchObject({ + generation: 1, + resumed: false + }) + expect((await coordinator.prepare({ + threadId: 'thread_1', + route: portableRoute, + priorItems: committedItems + })).rebaseReason).toBeUndefined() + }) + + test('keeps fork ids unbound and deletes one thread binding plus provider state', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-delegated-')) + const store = new FileDelegatedSessionBindingStore(root) + const coordinator = new DelegatedSessionCoordinator(store) + const prepared = await coordinator.prepare({ + threadId: 'thread_source', + route: route(), + priorItems: [] + }) + await coordinator.commit({ + preparation: prepared, + committedItems: [], + lastCommittedTurnId: 'turn_1', + nativeSessionId: 'agent_1' + }) + const stateDir = store.providerStateDir('cursor-sdk', 'thread_source') + await mkdir(stateDir, { recursive: true }) + await writeFile(join(stateDir, 'checkpoint'), 'opaque') + + expect(await store.load('thread_fork')).toBeNull() + await coordinator.invalidate('thread_source') + expect(await store.load('thread_source')).toBeNull() + await expect(access(stateDir)).rejects.toMatchObject({ code: 'ENOENT' }) + }) +}) diff --git a/kun/src/runtime/delegated-session-binding.ts b/kun/src/runtime/delegated-session-binding.ts new file mode 100644 index 000000000..a5fa7a4e9 --- /dev/null +++ b/kun/src/runtime/delegated-session-binding.ts @@ -0,0 +1,435 @@ +import { createHash, randomUUID } from 'node:crypto' +import { mkdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import type { TurnItem } from '../contracts/items.js' +import { effectiveHistoryAfterLatestCompaction } from '../loop/compaction-history.js' + +export type DelegatedProviderKind = 'agent-sdk' | 'cursor-sdk' | 'antigravity-cli' +export type DelegatedContinuationMode = 'native' | 'portable' + +export type DelegatedSessionRoute = { + providerKind: DelegatedProviderKind + providerId: string + credentialIdentity: string + workspace: string + model: string + capabilityFingerprint: string + continuationMode: DelegatedContinuationMode +} + +export type DelegatedSessionBinding = DelegatedSessionRoute & { + schemaVersion: 1 + threadId: string + generation: number + nativeSessionId?: string + synchronizedHistoryDigest: string + lastCommittedTurnId: string + createdAt: string + updatedAt: string +} + +export type DelegatedSessionPreparation = { + threadId: string + generation: number + route: DelegatedSessionRoute + priorHistoryDigest: string + nativeSessionId?: string + resumed: boolean + rebaseReason?: + | 'new' + | 'route_changed' + | 'capabilities_changed' + | 'history_changed' + | 'native_state_unavailable' +} + +export interface DelegatedSessionBindingStore { + load(threadId: string): Promise + save(binding: DelegatedSessionBinding): Promise + delete(threadId: string): Promise + clearProviderState(providerKind: DelegatedProviderKind, threadId: string): Promise + providerStateDir(providerKind: DelegatedProviderKind, threadId: string): string +} + +const BINDING_SCHEMA_VERSION = 1 +const MAX_NATIVE_SESSION_ID_LENGTH = 1_024 +const MAX_IDENTITY_LENGTH = 1_024 + +export class FileDelegatedSessionBindingStore implements DelegatedSessionBindingStore { + private readonly bindingDir: string + private readonly stateDir: string + + constructor(private readonly rootDir: string) { + this.bindingDir = join(rootDir, 'bindings') + this.stateDir = join(rootDir, 'provider-state') + } + + async load(threadId: string): Promise { + const path = this.bindingPath(threadId) + try { + const parsed = JSON.parse(await readFile(path, 'utf8')) as unknown + const binding = parseBinding(parsed) + if (!binding || binding.threadId !== threadId) { + await unlink(path).catch(() => undefined) + return null + } + return binding + } catch (error) { + if (isMissingFile(error)) return null + await unlink(path).catch(() => undefined) + return null + } + } + + async save(binding: DelegatedSessionBinding): Promise { + const parsed = parseBinding(binding) + if (!parsed) throw new Error('invalid delegated session binding') + await mkdir(this.bindingDir, { recursive: true, mode: 0o700 }) + const target = this.bindingPath(binding.threadId) + const temporary = `${target}.${process.pid}.${randomUUID()}.tmp` + await writeFile(temporary, `${JSON.stringify(parsed, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600 + }) + try { + await rename(temporary, target) + } catch (error) { + await unlink(temporary).catch(() => undefined) + throw error + } + } + + async delete(threadId: string): Promise { + await Promise.allSettled([ + unlink(this.bindingPath(threadId)), + rm(this.providerStateRoot(threadId), { recursive: true, force: true }) + ]) + } + + async clearProviderState( + providerKind: DelegatedProviderKind, + threadId: string + ): Promise { + const directory = this.providerStateDir(providerKind, threadId) + await rm(directory, { recursive: true, force: true }) + await mkdir(directory, { recursive: true, mode: 0o700 }) + } + + providerStateDir(providerKind: DelegatedProviderKind, threadId: string): string { + return join(this.providerStateRoot(threadId), providerKind) + } + + private bindingPath(threadId: string): string { + return join(this.bindingDir, `${threadKey(threadId)}.json`) + } + + private providerStateRoot(threadId: string): string { + return join(this.stateDir, threadKey(threadId)) + } +} + +export class DelegatedSessionCoordinator { + private readonly leases = new Map>() + + constructor( + readonly store: DelegatedSessionBindingStore, + private readonly nowIso: () => string = () => new Date().toISOString() + ) {} + + async runExclusive(threadId: string, operation: () => Promise): Promise { + const previous = this.leases.get(threadId) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolveLease) => { + release = resolveLease + }) + const tail = previous.catch(() => undefined).then(() => current) + this.leases.set(threadId, tail) + await previous.catch(() => undefined) + try { + return await operation() + } finally { + release() + if (this.leases.get(threadId) === tail) this.leases.delete(threadId) + } + } + + async prepare(input: { + threadId: string + route: DelegatedSessionRoute + priorItems: readonly TurnItem[] + }): Promise { + const priorHistoryDigest = delegatedHistoryDigest(input.priorItems) + const binding = await this.store.load(input.threadId) + const routeMatches = binding ? sameRoute(binding, input.route) : false + const portableAligned = Boolean( + binding && + routeMatches && + input.route.continuationMode === 'portable' && + binding.continuationMode === 'portable' && + binding.synchronizedHistoryDigest === priorHistoryDigest + ) + const canResume = Boolean( + binding && + routeMatches && + input.route.continuationMode === 'native' && + binding.continuationMode === 'native' && + binding.nativeSessionId && + binding.synchronizedHistoryDigest === priorHistoryDigest + ) + if (binding && canResume) { + return { + threadId: input.threadId, + generation: binding.generation, + route: input.route, + priorHistoryDigest, + nativeSessionId: binding.nativeSessionId, + resumed: true + } + } + if (binding && portableAligned) { + return { + threadId: input.threadId, + generation: binding.generation, + route: input.route, + priorHistoryDigest, + resumed: false + } + } + if (binding) { + const providerKinds = new Set([ + binding.providerKind, + input.route.providerKind + ]) + await Promise.all( + [...providerKinds].map((providerKind) => + this.store.clearProviderState(providerKind, input.threadId) + ) + ) + } + return { + threadId: input.threadId, + generation: (binding?.generation ?? 0) + 1, + route: input.route, + priorHistoryDigest, + resumed: false, + rebaseReason: rebaseReason(binding, input.route, priorHistoryDigest) + } + } + + async commit(input: { + preparation: DelegatedSessionPreparation + committedItems: readonly TurnItem[] + lastCommittedTurnId: string + nativeSessionId?: string + }): Promise { + const previous = await this.store.load(input.preparation.threadId) + if ( + previous && + previous.generation > input.preparation.generation + ) { + throw new Error('delegated session generation was superseded') + } + const now = this.nowIso() + const nativeSessionId = validNativeSessionId(input.nativeSessionId) + const continuationMode = + input.preparation.route.continuationMode === 'native' && nativeSessionId + ? 'native' + : 'portable' + const binding: DelegatedSessionBinding = { + schemaVersion: BINDING_SCHEMA_VERSION, + threadId: input.preparation.threadId, + generation: input.preparation.generation, + ...input.preparation.route, + continuationMode, + ...(nativeSessionId ? { nativeSessionId } : {}), + synchronizedHistoryDigest: delegatedHistoryDigest(input.committedItems), + lastCommittedTurnId: input.lastCommittedTurnId, + createdAt: + previous?.generation === input.preparation.generation + ? previous.createdAt + : now, + updatedAt: now + } + await this.store.save(binding) + return binding + } + + async rejectResume( + preparation: DelegatedSessionPreparation + ): Promise { + await this.store.clearProviderState( + preparation.route.providerKind, + preparation.threadId + ) + return { + ...preparation, + generation: preparation.generation + 1, + nativeSessionId: undefined, + resumed: false, + rebaseReason: 'native_state_unavailable' + } + } + + async invalidate(threadId: string): Promise { + await this.runExclusive(threadId, () => this.store.delete(threadId)) + } +} + +export function delegatedHistoryDigest(items: readonly TurnItem[]): string { + const effective = effectiveHistoryAfterLatestCompaction(items) + return sha256(stableStringify(effective.map(digestItem))) +} + +export function delegatedCapabilityFingerprint(value: unknown): string { + return sha256(stableStringify(value)) +} + +export function delegatedCredentialIdentity(input: { + providerId: string + accountId?: string + credentialSourceId?: string + credentialSecret?: string +}): string { + const parts = [ + ...(input.accountId?.trim() ? [`account:${input.accountId.trim()}`] : []), + ...(input.credentialSourceId?.trim() + ? [`credential-source:${input.credentialSourceId.trim()}`] + : []), + ...(input.credentialSecret?.trim() + ? [`credential-secret:${input.credentialSecret.trim()}`] + : []) + ] + if (parts.length === 0) { + parts.push(`provider-config:${input.providerId.trim() || 'default'}`) + } + return `sha256:${sha256(parts.join('\n'))}` +} + +export function priorItemsForDelegatedTurn( + items: readonly TurnItem[], + currentTurnId: string +): TurnItem[] { + return items.filter((item) => item.turnId !== currentTurnId) +} + +function sameRoute( + binding: DelegatedSessionBinding, + route: DelegatedSessionRoute +): boolean { + return binding.providerKind === route.providerKind && + binding.providerId === route.providerId && + binding.credentialIdentity === route.credentialIdentity && + binding.workspace === route.workspace && + binding.model === route.model && + binding.capabilityFingerprint === route.capabilityFingerprint && + binding.continuationMode === route.continuationMode +} + +function rebaseReason( + binding: DelegatedSessionBinding | null, + route: DelegatedSessionRoute, + historyDigest: string +): DelegatedSessionPreparation['rebaseReason'] { + if (!binding) return 'new' + if ( + binding.providerKind !== route.providerKind || + binding.providerId !== route.providerId || + binding.credentialIdentity !== route.credentialIdentity || + binding.workspace !== route.workspace || + binding.model !== route.model || + binding.continuationMode !== route.continuationMode + ) return 'route_changed' + if (binding.capabilityFingerprint !== route.capabilityFingerprint) { + return 'capabilities_changed' + } + if (binding.synchronizedHistoryDigest !== historyDigest) return 'history_changed' + return 'native_state_unavailable' +} + +function digestItem(item: TurnItem): unknown { + const { + createdAt: _createdAt, + finishedAt: _finishedAt, + ...semantic + } = item + return semantic +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]` + const record = value as Record + return `{${Object.keys(record).sort().map((key) => + `${JSON.stringify(key)}:${stableStringify(record[key])}` + ).join(',')}}` +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex') +} + +function threadKey(threadId: string): string { + return sha256(threadId) +} + +function validNativeSessionId(value: string | undefined): string | undefined { + const normalized = value?.trim() + return normalized && normalized.length <= MAX_NATIVE_SESSION_ID_LENGTH + ? normalized + : undefined +} + +function parseBinding(value: unknown): DelegatedSessionBinding | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const record = value as Record + const providerKind = record.providerKind + const continuationMode = record.continuationMode + if ( + record.schemaVersion !== BINDING_SCHEMA_VERSION || + typeof record.threadId !== 'string' || + !record.threadId || + !Number.isInteger(record.generation) || + Number(record.generation) < 1 || + ( + providerKind !== 'agent-sdk' && + providerKind !== 'cursor-sdk' && + providerKind !== 'antigravity-cli' + ) || + (continuationMode !== 'native' && continuationMode !== 'portable') || + !boundedString(record.providerId) || + !boundedString(record.credentialIdentity) || + !boundedString(record.workspace, 16_384) || + !boundedString(record.model) || + !hexDigest(record.capabilityFingerprint) || + !hexDigest(record.synchronizedHistoryDigest) || + !boundedString(record.lastCommittedTurnId) || + !boundedString(record.createdAt) || + !boundedString(record.updatedAt) || + ( + record.nativeSessionId !== undefined && + !boundedString(record.nativeSessionId, MAX_NATIVE_SESSION_ID_LENGTH) + ) + ) return null + return record as DelegatedSessionBinding +} + +function boundedString(value: unknown, max = MAX_IDENTITY_LENGTH): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= max +} + +function hexDigest(value: unknown): value is string { + return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) +} + +function isMissingFile(error: unknown): boolean { + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + (error as { code?: unknown }).code === 'ENOENT' + ) +} + +export function delegatedSessionRoot(dataDir: string): string { + return resolve(dataDir, 'delegated-sessions') +} diff --git a/kun/src/runtime/delegated-turn-runtime.ts b/kun/src/runtime/delegated-turn-runtime.ts index c7758ab6a..552517c83 100644 --- a/kun/src/runtime/delegated-turn-runtime.ts +++ b/kun/src/runtime/delegated-turn-runtime.ts @@ -1,9 +1,20 @@ +export type DelegatedRuntimeCapabilities = { + nativeResume: boolean + structuredStreaming: boolean + kunTools: boolean + externalApproval: boolean + liveSteering: boolean + nativeContextTelemetry: boolean + fork: boolean +} + /** * A provider-native runtime that owns an entire Kun turn instead of exposing an * HTTP ModelClient. Subscription CLIs/SDKs implement this narrow boundary. */ export interface DelegatedTurnRuntime { handlesProvider(providerId: string | undefined): boolean + capabilities(providerId: string | undefined): DelegatedRuntimeCapabilities | undefined runTurn( threadId: string, turnId: string, @@ -21,6 +32,10 @@ export function composeDelegatedTurnRuntimes( handlesProvider(providerId) { return active.some((runtime) => runtime.handlesProvider(providerId)) }, + capabilities(providerId) { + return active.find((candidate) => candidate.handlesProvider(providerId)) + ?.capabilities(providerId) + }, async runTurn(threadId, turnId, signal, providerId) { const runtime = active.find((candidate) => candidate.handlesProvider(providerId)) if (runtime) return runtime.runTurn(threadId, turnId, signal, providerId) diff --git a/kun/src/security/secret-store.test.ts b/kun/src/security/secret-store.test.ts index 1c6bc5b30..0b2ac90ad 100644 --- a/kun/src/security/secret-store.test.ts +++ b/kun/src/security/secret-store.test.ts @@ -8,7 +8,9 @@ import { createSecretEncryptor, DISABLE_OS_CREDENTIAL_STORE_ENV, hasPersistedSecretKeyMaterial, - isEncryptedEnvelope + isEncryptedEnvelope, + UNREADABLE_CREDENTIAL_KEY_ERROR_CODE, + WINDOWS_DPAPI_KEY_PREFIX } from './secret-store.js' const isolatedCredentialEnvironment = { @@ -298,6 +300,25 @@ describe('createSecretEncryptor', () => { } }) + it('does not replace an existing DPAPI key when Windows can no longer decrypt it', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kun-secret-')) + const keyPath = join(dir, 'secret.key') + const protectedKey = `${WINDOWS_DPAPI_KEY_PREFIX}unreadable-envelope` + const run = vi.fn(async () => ({ code: 1, stdout: '', stderr: 'CryptUnprotectData failed' })) + try { + await writeFile(keyPath, protectedKey) + await expect(createSecretEncryptor({ + keyFilePath: keyPath, + platform: 'win32', + run, + ...explicitOsCredentialStore + })).rejects.toMatchObject({ code: UNREADABLE_CREDENTIAL_KEY_ERROR_CODE }) + await expect(readFile(keyPath, 'utf8')).resolves.toBe(protectedKey) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + it('migrates an existing raw key to DPAPI without changing the encryption key', async () => { const run = vi.fn(async (_cmd: string, args: string[], input?: string) => { const script = args[args.length - 1] diff --git a/kun/src/security/secret-store.ts b/kun/src/security/secret-store.ts index 59a496fbe..dacba3230 100644 --- a/kun/src/security/secret-store.ts +++ b/kun/src/security/secret-store.ts @@ -236,7 +236,25 @@ async function tryWriteOsKey(platform: NodeJS.Platform, run: CommandRunner, key: } /** Marker for a DPAPI-wrapped key file (Windows). */ -const DPAPI_PREFIX = 'dpapi:v1:' +export const WINDOWS_DPAPI_KEY_PREFIX = 'dpapi:v1:' +export const UNREADABLE_CREDENTIAL_KEY_ERROR_CODE = 'credential_key_unreadable' + +export class UnreadableCredentialKeyError extends Error { + readonly code = UNREADABLE_CREDENTIAL_KEY_ERROR_CODE + + constructor() { + super( + `${UNREADABLE_CREDENTIAL_KEY_ERROR_CODE}: existing DPAPI-protected OAuth key could not be decrypted; refusing to replace it` + ) + this.name = 'UnreadableCredentialKeyError' + } +} + +export function isUnreadableCredentialKeyError(error: unknown): error is UnreadableCredentialKeyError { + return error instanceof UnreadableCredentialKeyError || ( + error instanceof Error && error.message.includes(UNREADABLE_CREDENTIAL_KEY_ERROR_CODE) + ) +} /** * Windows DPAPI key wrapping. The 32-byte AES key is encrypted with the current @@ -287,8 +305,8 @@ async function readDpapiKeyFile(path: string, run: CommandRunner): Promise { @@ -421,13 +439,13 @@ export async function createSecretEncryptor(options: CreateKeyProviderOptions): if (existing) { return { encryptor: createAesEncryptor(existing), osKeychain: true, reason: 'key DPAPI-protected (CurrentUser) in key file' } } - if (keyFileText.trim().startsWith(DPAPI_PREFIX)) { - throw new Error('existing DPAPI-protected OAuth key could not be decrypted; refusing to replace it') + if (keyFileText.trim().startsWith(WINDOWS_DPAPI_KEY_PREFIX)) { + throw new UnreadableCredentialKeyError() } const key = legacyFileKey ?? randomBytes(32) const wrapped = await dpapiProtect(run, key) if (wrapped) { - await writeKeyFileContent(options.keyFilePath, `${DPAPI_PREFIX}${wrapped}`) + await writeKeyFileContent(options.keyFilePath, `${WINDOWS_DPAPI_KEY_PREFIX}${wrapped}`) return { encryptor: createAesEncryptor(key), osKeychain: true, diff --git a/kun/src/server/routes/attachments.ts b/kun/src/server/routes/attachments.ts index 51649c0a1..92a385514 100644 --- a/kun/src/server/routes/attachments.ts +++ b/kun/src/server/routes/attachments.ts @@ -60,9 +60,12 @@ export async function uploadAttachment( mimeType: parsed.data.mimeType, data, documentText: parsed.data.documentText, + documentFormat: parsed.data.documentFormat, + sourceSha256: parsed.data.sourceSha256, pageCount: parsed.data.pageCount, localFilePath: parsed.data.localFilePath, textFallback: parsed.data.textFallback, + visualPreview: parsed.data.visualPreview, threadId: parsed.data.threadId, workspace: parsed.data.workspace }) diff --git a/kun/src/server/runtime-factory.ts b/kun/src/server/runtime-factory.ts index abad68617..6f766899e 100644 --- a/kun/src/server/runtime-factory.ts +++ b/kun/src/server/runtime-factory.ts @@ -13,6 +13,7 @@ import { InMemoryEventBus } from '../adapters/in-memory-event-bus.js' import { FileSessionStore, FileThreadStore } from '../adapters/file/index.js' import { HybridSessionStore, HybridThreadStore } from '../adapters/hybrid/index.js' import { CompatModelClient } from '../adapters/model/compat-model-client.js' +import { GeminiCliApiModelClient } from '../adapters/model/gemini-cli-api-model-client.js' import { ExtensionModelProviderRegistry } from '../adapters/model/extension-model-provider.js' import { MultiProviderModelClient } from '../adapters/model/multi-provider-model-client.js' import { RoutePoolHealthStore, RoutePoolModelClient } from '../adapters/model/route-pool-model-client.js' @@ -26,10 +27,15 @@ import { type AntigravityCliRuntimeDeps } from '../runtime/antigravity/antigravity-cli-runtime.js' import { - CursorSdkRuntime, - type CursorSdkRuntimeDeps -} from '../runtime/cursor/cursor-sdk-runtime.js' + createCursorSdkRuntime, + type CursorSdkRuntimeFactoryDeps +} from '../runtime/cursor/cursor-sdk-runtime-factory.js' import { composeDelegatedTurnRuntimes } from '../runtime/delegated-turn-runtime.js' +import { + DelegatedSessionCoordinator, + FileDelegatedSessionBindingStore, + delegatedSessionRoot +} from '../runtime/delegated-session-binding.js' import { buildGoalLocalTools } from '../adapters/tool/goal-tools.js' import { buildTodoLocalTools } from '../adapters/tool/todo-tools.js' import { buildDesignCanvasLocalTools } from '../adapters/tool/design-canvas-tool.js' @@ -50,6 +56,7 @@ import { buildComponentDesignToolProviders } from '../adapters/tool/component-de import { buildWebToolProviders } from '../adapters/tool/web-tool-provider.js' import { buildImageGenToolProviders } from '../adapters/tool/image-gen-tool-provider.js' import { buildComputerUseToolProviders } from '../adapters/tool/computer-use-tool-provider.js' +import { buildOfficeCliToolProviders } from '../adapters/tool/office-cli-tool-provider.js' import { buildMusicGenToolProviders, buildSpeechGenToolProviders, @@ -66,8 +73,10 @@ import { AgentLoop, type AgentLoopOptions } from '../loop/agent-loop.js' import { ContextCompactor } from '../loop/context-compactor.js' import type { TokenEconomyConfig } from '../loop/token-economy.js' import { + DEFAULT_CONTEXT_THRESHOLDS, modelCapabilitiesForModel, modelContextProfilesFromConfig, + contextThresholdsForModel, type ContextCompactionConfig, type ModelConfig } from '../loop/model-context-profile.js' @@ -169,6 +178,7 @@ import { LegacyProviderCredentialMigrationService, materializeLegacyProviderCredential } from '../services/legacy-provider-credential-migration.js' +import { CodexOAuthCredentialRefresher } from '../services/codex-oauth-credential-refresher.js' import { GrokOAuthCredentialRefresher } from '../services/grok-oauth-credential-refresher.js' import { ExtensionViewSessionService } from '../services/extension-view-session-service.js' import { ExtensionViewHostGenerationTracker } from '../extensions/view-host-generation-tracker.js' @@ -282,10 +292,19 @@ export async function createKunServeRuntime( const usageService = new UsageService() const inflight = new InflightTracker() const steering = new SteeringQueue() - const compactor = new ContextCompactor({ + let modelProfiles = modelContextProfilesFromConfig({ contextCompaction: activeOptions.contextCompaction, models: activeOptions.models }) + let providerModelProfiles = modelContextProfilesByProvider(activeOptions.providers) + const profilesForProvider = (providerId?: string) => providerId + ? providerModelProfiles.get(providerId.trim().toLowerCase()) ?? modelProfiles + : modelProfiles + const compactor = new ContextCompactor({ + contextCompaction: activeOptions.contextCompaction, + models: activeOptions.models, + profilesForProvider + }) let tokenEconomy = tokenEconomyConfigForOptions(activeOptions) const ids = new RandomIdGenerator() const nowIso = () => new Date().toISOString() @@ -315,6 +334,10 @@ export async function createKunServeRuntime( }) let abortThreadExecution: ((threadId: string) => number) | undefined let stopThreadAuxiliaryWork: ((threadId: string) => Promise) | undefined + const delegatedSessions = new DelegatedSessionCoordinator( + new FileDelegatedSessionBindingStore(delegatedSessionRoot(activeOptions.dataDir)), + nowIso + ) const threadService = new ThreadService({ threadStore, deleteThreadStore: rawThreadStore, @@ -334,15 +357,33 @@ export async function createKunServeRuntime( usageService.reset(threadId) events.clearThread(threadId) eventBus.clearThread(threadId) - await llmDebug.deleteThread(threadId) + await Promise.all([ + llmDebug.deleteThread(threadId), + delegatedSessions.invalidate(threadId) + ]) } }) const artifactStore = new FileArtifactStore(join(activeOptions.dataDir, 'artifacts'), nowIso) - let modelProfiles = modelContextProfilesFromConfig({ - contextCompaction: activeOptions.contextCompaction, - models: activeOptions.models - }) - const modelCapabilities = (model: string) => modelCapabilitiesForModel(model, modelProfiles) + const modelCapabilities = (model: string, providerId?: string) => modelCapabilitiesForModel( + model, + profilesForProvider(providerId) + ) + const delegatedContextProfile = (model: string) => { + const thresholds = contextThresholdsForModel(model, { + softThreshold: + activeOptions.contextCompaction?.defaultSoftThreshold ?? + DEFAULT_CONTEXT_THRESHOLDS.softThreshold, + hardThreshold: + activeOptions.contextCompaction?.defaultHardThreshold ?? + DEFAULT_CONTEXT_THRESHOLDS.hardThreshold + }, modelProfiles) + return { + contextWindowTokens: modelCapabilities(model).contextWindowTokens ?? + Math.max(thresholds.softThreshold, thresholds.hardThreshold), + softThresholdTokens: thresholds.softThreshold, + hardThresholdTokens: thresholds.hardThreshold + } + } // Provider-native subscription transports don't get an HTTP client. const agentSdkProviderIds = agentSdkProviderIdsForOptions(activeOptions) const antigravityProviderIds = antigravityProviderIdsForOptions(activeOptions) @@ -384,6 +425,9 @@ export async function createKunServeRuntime( const grokCredentialRefresher = new GrokOAuthCredentialRefresher( legacyCredentialMigration ) + const codexCredentialRefresher = new CodexOAuthCredentialRefresher( + legacyCredentialMigration + ) const resolveLegacyRequestCredentials = async ( sourceId: string, rejectedAccessToken?: string @@ -392,7 +436,10 @@ export async function createKunServeRuntime( headers?: Record refreshable: boolean }> => { - const resolved = await grokCredentialRefresher.resolve(sourceId, rejectedAccessToken) + let resolved = await codexCredentialRefresher.resolve(sourceId, rejectedAccessToken) + if (!resolved.refreshable) { + resolved = await grokCredentialRefresher.resolve(sourceId, rejectedAccessToken) + } const material = materializeLegacyProviderCredential(resolved.rawApiKey) return { ...material, @@ -494,6 +541,7 @@ export async function createKunServeRuntime( ]) const instructionRuntime = new InstructionRuntime(activeOptions.capabilities?.instructions) const migrationMaintenance = new ScopedMigrationMaintenanceLock() + let attachmentStore: FileAttachmentStore | undefined const turnService = new TurnService({ threadStore, sessionStore, @@ -504,10 +552,12 @@ export async function createKunServeRuntime( model: modelClient, usage: usageService, prefix, + attachmentStore: () => attachmentStore, defaultModel: options.model, contextCompaction: options.contextCompaction, maxConcurrentTurns: activeOptions.runtime?.turnLimits?.maxConcurrentTurns, lifecycleFence, + onCompacted: (threadId) => delegatedSessions.invalidate(threadId), migrationMaintenance, ids, nowIso @@ -548,6 +598,7 @@ export async function createKunServeRuntime( defaultModel: activeOptions.model, nowIso, modelCapabilities, + profilesForProvider, ...(activeOptions.models ? { models: activeOptions.models } : {}), ...(activeOptions.contextCompaction ? { contextCompaction: activeOptions.contextCompaction } : {}), ...(tokenEconomy ? { tokenEconomy } : {}), @@ -561,7 +612,7 @@ export async function createKunServeRuntime( } const reviewService = new ReviewService(reviewDeps) let webProviders = buildWebToolProviders(activeOptions.capabilities?.web) - let attachmentStore = activeOptions.capabilities?.attachments.enabled + attachmentStore = activeOptions.capabilities?.attachments.enabled ? new FileAttachmentStore({ rootDir: join(activeOptions.dataDir, 'attachments'), config: activeOptions.capabilities.attachments, @@ -594,7 +645,8 @@ export async function createKunServeRuntime( maintenance: migrationMaintenance, attachmentStore: () => attachmentStore, artifactStore, - memoryStore: () => memoryStore + memoryStore: () => memoryStore, + onThreadImported: (threadId) => delegatedSessions.invalidate(threadId) }) let imageGenProviders = buildImageGenToolProviders(activeOptions.capabilities?.imageGen, { attachmentStore, @@ -624,6 +676,10 @@ export async function createKunServeRuntime( available: true, tools: buildPptMasterLocalTools() } + const officeCliProviders = buildOfficeCliToolProviders({ + binaryPath: process.env.KUN_OFFICECLI_BINARY, + profileDir: join(activeOptions.dataDir, 'officecli-profile') + }) const taskGraphTool = createTaskGraphTool({ rootDir: join(activeOptions.dataDir, 'task-graphs') }) let baseToolProviders = [ { @@ -651,6 +707,7 @@ export async function createKunServeRuntime( ...speechGenProviders.providers, ...musicGenProviders.providers, ...videoGenProviders.providers, + ...officeCliProviders, pptMasterProvider, designCanvasProvider, // NOTE: computer_use is intentionally NOT in baseToolProviders — host @@ -683,6 +740,7 @@ export async function createKunServeRuntime( sessionStore: child.sessionStore, threadStore: child.threadStore, events: child.events, + debugSink: llmDebug, ids: child.ids, prefix: child.prefix, providerConfigs: activeOptions.providers ?? {}, @@ -709,7 +767,9 @@ export async function createKunServeRuntime( ...(process.env.KUN_CLAUDE_BINARY ? { pathToClaudeCodeExecutable: process.env.KUN_CLAUDE_BINARY } : {}), - nowIso + nowIso, + sessionCoordinator: delegatedSessions, + contextProfile: delegatedContextProfile })] : []), ...(antigravityProviderIds.size > 0 || defaultIsAntigravity @@ -727,16 +787,22 @@ export async function createKunServeRuntime( ids: child.ids, debugSink: llmDebug, turnLimits: activeOptions.runtime?.turnLimits, - enforceReadOnly: child.toolPolicy === 'readOnly' + enforceReadOnly: child.toolPolicy === 'readOnly', + sessionCoordinator: delegatedSessions, + contextProfile: delegatedContextProfile })] : []), ...(cursorSdkProviderIds.size > 0 || defaultIsCursorSdk - ? [new CursorSdkRuntime({ + ? [createCursorSdkRuntime({ + registry: childRegistry, + toolHost: childToolHost, providerConfigs: activeOptions.providers ?? {}, providerIds: cursorSdkProviderIds, defaultIsCursor: defaultIsCursorSdk, defaultApiKey: activeOptions.apiKey, defaultModel: activeOptions.model, + defaultApprovalPolicy: activeOptions.approvalPolicy, + defaultSandboxMode: activeOptions.sandboxMode, systemPrompt: child.prefix.systemPrompt, threadStore: child.threadStore, sessionStore: child.sessionStore, @@ -746,7 +812,21 @@ export async function createKunServeRuntime( debugSink: llmDebug, ...(attachmentStore ? { attachmentStore } : {}), turnLimits: activeOptions.runtime?.turnLimits, - enforceReadOnly: child.toolPolicy === 'readOnly' + enforceReadOnly: child.toolPolicy === 'readOnly', + approvalGate, + instructionRuntime, + toolContextBoundary: { + ...(child.allowedProviderIds ? { allowedProviderIds: child.allowedProviderIds } : {}), + ...(child.allowedToolNames ? { allowedToolNames: child.allowedToolNames } : {}), + ...(child.blockedProviderIds ? { blockedProviderIds: child.blockedProviderIds } : {}), + ...(child.blockedToolNames ? { blockedToolNames: child.blockedToolNames } : {}), + ...(child.blockedSkillIds ? { blockedSkillIds: child.blockedSkillIds } : {}) + }, + ...(child.skillsEnabled ? { skillRuntime } : {}), + ...(child.memoryEnabled && memoryStore ? { memoryStore } : {}), + nowIso, + sessionCoordinator: delegatedSessions, + contextProfile: delegatedContextProfile })] : []) ]) @@ -768,6 +848,7 @@ export async function createKunServeRuntime( approvalPolicy: activeOptions.approvalPolicy, sandboxMode: activeOptions.sandboxMode, modelCapabilities, + profilesForProvider, skillRuntime, instructionRuntime, tokenEconomy, @@ -912,6 +993,7 @@ export async function createKunServeRuntime( sessionStore, threadStore, events, + debugSink: llmDebug, ids, prefix, providerConfigs: activeOptions.providers ?? {}, @@ -931,7 +1013,9 @@ export async function createKunServeRuntime( ...(memoryStore ? { memoryStore } : {}), ...(process.env.KUN_CLAUDE_BINARY ? { pathToClaudeCodeExecutable: process.env.KUN_CLAUDE_BINARY } - : {}) + : {}), + sessionCoordinator: delegatedSessions, + contextProfile: delegatedContextProfile } } let antigravityRuntimeDeps: AntigravityCliRuntimeDeps | undefined @@ -949,17 +1033,23 @@ export async function createKunServeRuntime( events, ids, debugSink: llmDebug, - turnLimits: activeOptions.runtime?.turnLimits + turnLimits: activeOptions.runtime?.turnLimits, + sessionCoordinator: delegatedSessions, + contextProfile: delegatedContextProfile } } - let cursorRuntimeDeps: CursorSdkRuntimeDeps | undefined + let cursorRuntimeDeps: CursorSdkRuntimeFactoryDeps | undefined if (cursorSdkProviderIds.size > 0 || defaultIsCursorSdk) { cursorRuntimeDeps = { + registry, + toolHost, providerConfigs: activeOptions.providers ?? {}, providerIds: cursorSdkProviderIds, defaultIsCursor: defaultIsCursorSdk, defaultApiKey: activeOptions.apiKey, defaultModel: activeOptions.model, + defaultApprovalPolicy: activeOptions.approvalPolicy, + defaultSandboxMode: activeOptions.sandboxMode, systemPrompt: prefix.systemPrompt, threadStore, sessionStore, @@ -967,8 +1057,16 @@ export async function createKunServeRuntime( events, ids, debugSink: llmDebug, + approvalGate, + userInputGate, + skillRuntime, + instructionRuntime, + nowIso, + ...(memoryStore ? { memoryStore } : {}), ...(attachmentStore ? { attachmentStore } : {}), - turnLimits: activeOptions.runtime?.turnLimits + turnLimits: activeOptions.runtime?.turnLimits, + sessionCoordinator: delegatedSessions, + contextProfile: delegatedContextProfile } } @@ -985,7 +1083,7 @@ export async function createKunServeRuntime( const sdkRuntime = composeDelegatedTurnRuntimes([ ...(sdkRuntimeDeps ? [createAgentSdkRuntime(sdkRuntimeDeps)] : []), ...(antigravityRuntimeDeps ? [new AntigravityCliRuntime(antigravityRuntimeDeps)] : []), - ...(cursorRuntimeDeps ? [new CursorSdkRuntime(cursorRuntimeDeps)] : []) + ...(cursorRuntimeDeps ? [createCursorSdkRuntime(cursorRuntimeDeps)] : []) ]) const loopOptions: AgentLoopOptions = { threadStore, @@ -1570,6 +1668,7 @@ export async function createKunServeRuntime( contextCompaction: nextOptions.contextCompaction, models: nextOptions.models }) + const nextProviderModelProfiles = modelContextProfilesByProvider(nextOptions.providers) const nextTokenEconomy = tokenEconomyConfigForOptions(nextOptions) const nextMcpHasOAuth = Object.values(nextOptions.capabilities?.mcp?.servers ?? {}).some((server) => server.oauth?.enabled !== false && Boolean(server.oauth) && server.transport !== 'stdio' @@ -1618,6 +1717,10 @@ export async function createKunServeRuntime( ...buildBuiltinHooks({ quality: nextOptions.quality ?? DEFAULT_QUALITY_CONFIG }), ...resolveConfiguredHooks(nextOptions.hooks) ] + const nextOfficeCliProviders = buildOfficeCliToolProviders({ + binaryPath: process.env.KUN_OFFICECLI_BINARY, + profileDir: join(nextOptions.dataDir, 'officecli-profile') + }) const nextBaseToolProviders = [ { id: 'builtin', @@ -1644,6 +1747,7 @@ export async function createKunServeRuntime( ...nextSpeechGenProviders.providers, ...nextMusicGenProviders.providers, ...nextVideoGenProviders.providers, + ...nextOfficeCliProviders, nextPptMasterProvider, designCanvasProvider ] @@ -1682,6 +1786,7 @@ export async function createKunServeRuntime( const previousMcpProviders = mcpProviders activeOptions = nextOptions modelProfiles = nextModelProfiles + providerModelProfiles = nextProviderModelProfiles tokenEconomy = nextTokenEconomy delegatedProviderSignature = nextDelegatedProviderSignature replaceRoutedModelClients() @@ -2034,7 +2139,10 @@ async function hydrateLegacyCredentialOptions( function buildModelClientRouterInput( options: KunServeRuntimeOptions, - modelCapabilities: (model: string) => ReturnType, + modelCapabilities: ( + model: string, + providerId?: string + ) => ReturnType, llmDebug?: LlmDebugRecorder, credentialResolver?: ( sourceId: string, @@ -2049,51 +2157,82 @@ function buildModelClientRouterInput( options.runtime?.streamIdleTimeoutMs !== undefined ? { streamIdleTimeoutMs: options.runtime.streamIdleTimeoutMs } : {} - const defaultClient: ModelClient = new CompatModelClient({ - baseUrl: options.baseUrl, - apiKey: options.apiKey, - modelProxyUrl: options.modelProxyUrl, - endpointFormat: options.endpointFormat ?? DEFAULT_MODEL_ENDPOINT_FORMAT, - retry: options.retry, - model: options.model, - modelCapabilities, - headers: options.headers, - ...(options.credentialSourceId && credentialResolver - ? { - resolveCredentials: (rejectedAccessToken?: string) => - credentialResolver(options.credentialSourceId!, rejectedAccessToken) - } - : {}), - ...(llmDebug ? { debugSink: llmDebug } : {}), - ...streamIdleOverride - }) + const defaultClient: ModelClient = + process.env.KUN_RUNTIME_PROVIDER_KIND === 'gemini-cli-api' + ? new GeminiCliApiModelClient({ + model: options.model, + modelProxyUrl: options.modelProxyUrl, + retry: options.retry, + ...(llmDebug ? { debugSink: llmDebug } : {}) + }) + : new CompatModelClient({ + baseUrl: options.baseUrl, + apiKey: options.apiKey, + modelProxyUrl: options.modelProxyUrl, + endpointFormat: options.endpointFormat ?? DEFAULT_MODEL_ENDPOINT_FORMAT, + retry: options.retry, + model: options.model, + modelCapabilities: (model) => modelCapabilities(model), + headers: options.headers, + ...(options.credentialSourceId && credentialResolver + ? { + resolveCredentials: (rejectedAccessToken?: string) => + credentialResolver(options.credentialSourceId!, rejectedAccessToken) + } + : {}), + ...(llmDebug ? { debugSink: llmDebug } : {}), + ...streamIdleOverride + }) const providerClients = new Map() for (const [providerId, provider] of Object.entries(options.providers ?? {})) { const trimmedId = providerId.trim() - if (!trimmedId || (provider.kind ?? 'http') !== 'http') continue - const client: ModelClient = new CompatModelClient({ - baseUrl: provider.baseUrl ?? options.baseUrl ?? '', - apiKey: provider.apiKey, - modelProxyUrl: provider.modelProxyUrl ?? options.modelProxyUrl, - endpointFormat: provider.endpointFormat ?? options.endpointFormat ?? DEFAULT_MODEL_ENDPOINT_FORMAT, - retry: provider.retry ?? options.retry, - model: options.model, - modelCapabilities, - headers: provider.headers, - ...(provider.credentialSourceId && credentialResolver - ? { - resolveCredentials: (rejectedAccessToken?: string) => - credentialResolver(provider.credentialSourceId!, rejectedAccessToken) - } - : {}), - ...(llmDebug ? { debugSink: llmDebug } : {}), - ...streamIdleOverride - }) + if (!trimmedId) continue + const kind = provider.kind ?? 'http' + if (kind !== 'http' && kind !== 'gemini-cli-api') continue + const client: ModelClient = kind === 'gemini-cli-api' + ? new GeminiCliApiModelClient({ + model: options.model, + modelProxyUrl: provider.modelProxyUrl ?? options.modelProxyUrl, + retry: provider.retry ?? options.retry, + ...(llmDebug ? { debugSink: llmDebug } : {}) + }) + : new CompatModelClient({ + baseUrl: provider.baseUrl ?? options.baseUrl ?? '', + apiKey: provider.apiKey, + modelProxyUrl: provider.modelProxyUrl ?? options.modelProxyUrl, + endpointFormat: provider.endpointFormat ?? options.endpointFormat ?? DEFAULT_MODEL_ENDPOINT_FORMAT, + retry: provider.retry ?? options.retry, + model: options.model, + modelCapabilities: (model) => modelCapabilities(model, trimmedId), + headers: provider.headers, + ...(provider.credentialSourceId && credentialResolver + ? { + resolveCredentials: (rejectedAccessToken?: string) => + credentialResolver(provider.credentialSourceId!, rejectedAccessToken) + } + : {}), + ...(llmDebug ? { debugSink: llmDebug } : {}), + ...streamIdleOverride + }) providerClients.set(trimmedId, client) } return { default: defaultClient, providers: providerClients } } +function modelContextProfilesByProvider( + providers: KunServeRuntimeOptions['providers'] +): Map> { + const out = new Map>() + for (const [providerId, provider] of Object.entries(providers ?? {})) { + const normalized = providerId.trim().toLowerCase() + if (!normalized) continue + out.set(normalized, modelContextProfilesFromConfig({ + models: { profiles: provider.modelProfiles ?? {} } + })) + } + return out +} + function agentSdkProviderIdsForOptions(options: KunServeRuntimeOptions): Set { const out = new Set() for (const [providerId, provider] of Object.entries(options.providers ?? {})) { diff --git a/kun/src/services/codex-oauth-credential-refresher.test.ts b/kun/src/services/codex-oauth-credential-refresher.test.ts new file mode 100644 index 000000000..84fd4662c --- /dev/null +++ b/kun/src/services/codex-oauth-credential-refresher.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it, vi } from 'vitest' +import { CompatModelClient } from '../adapters/model/compat-model-client.js' +import type { ModelRequest, ModelStreamChunk } from '../ports/model-client.js' +import { materializeLegacyProviderCredential } from './legacy-provider-credential-migration.js' +import { + CODEX_OAUTH_CLIENT_ID, + CODEX_OAUTH_TOKEN_ENDPOINT, + CodexOAuthCredentialRefresher, + parseStoredCodexOAuthCredentials, + refreshStoredCodexOAuthCredentials, + type CodexRefreshableCredentialStore +} from './codex-oauth-credential-refresher.js' + +const NOW = Date.parse('2026-07-24T15:00:00.000Z') +const SOURCE_ID = 'settings:provider:codex' + +function encodedCredentials(overrides: Record = {}): string { + return JSON.stringify({ + kind: 'codex-oauth', + accessToken: 'old-access', + refreshToken: 'old-refresh', + expiresAt: NOW - 1, + accountId: 'acct-old', + email: 'old@example.com', + ...overrides + }) +} + +function memoryStore(initial: string): CodexRefreshableCredentialStore & { + current: string + updates: string[] +} { + return { + current: initial, + updates: [], + async resolveApiKey() { + return { apiKey: this.current } + }, + async updateResolvedApiKey(_sourceId, apiKey) { + this.current = apiKey + this.updates.push(apiKey) + return true + } + } +} + +function tokenFetch(response: Record = { + access_token: 'new-access', + expires_in: 3600 +}): { fetchImpl: typeof fetch; tokenPosts: ReturnType } { + const tokenPosts = vi.fn() + const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + expect(String(input)).toBe(CODEX_OAUTH_TOKEN_ENDPOINT) + tokenPosts(String(init?.body ?? '')) + return Response.json(response) + }) as unknown as typeof fetch + return { fetchImpl, tokenPosts } +} + +function modelRequest(): ModelRequest { + return { + threadId: 'thread-codex-refresh', + turnId: 'turn-codex-refresh', + model: 'gpt-5.6-sol', + systemPrompt: 'You are a helpful assistant.', + prefix: [], + history: [], + tools: [], + abortSignal: new AbortController().signal + } +} + +async function drain(iterable: AsyncIterable): Promise { + const chunks: ModelStreamChunk[] = [] + for await (const chunk of iterable) chunks.push(chunk) + return chunks +} + +describe('CodexOAuthCredentialRefresher', () => { + it('refreshes one expired protected credential for concurrent callers and persists it', async () => { + const store = memoryStore(encodedCredentials()) + const { fetchImpl, tokenPosts } = tokenFetch() + const refresher = new CodexOAuthCredentialRefresher(store, { + fetchImpl, + nowMs: () => NOW + }) + + const [first, second] = await Promise.all([ + refresher.resolve(SOURCE_ID), + refresher.resolve(SOURCE_ID) + ]) + + expect(tokenPosts).toHaveBeenCalledTimes(1) + const tokenBody = new URLSearchParams(tokenPosts.mock.calls[0]?.[0]) + expect(tokenBody.get('grant_type')).toBe('refresh_token') + expect(tokenBody.get('refresh_token')).toBe('old-refresh') + expect(tokenBody.get('client_id')).toBe(CODEX_OAUTH_CLIENT_ID) + expect(first.refreshable).toBe(true) + expect(second.refreshable).toBe(true) + expect(store.updates).toHaveLength(1) + expect(parseStoredCodexOAuthCredentials(store.current)).toMatchObject({ + accessToken: 'new-access', + refreshToken: 'old-refresh', + expiresAt: NOW + 3_600_000, + accountId: 'acct-old', + email: 'old@example.com' + }) + }) + + it('reuses an unexpired credential outside the early-invalidation window', async () => { + const store = memoryStore(encodedCredentials({ expiresAt: NOW + 10 * 60_000 })) + const fetchImpl = vi.fn() as unknown as typeof fetch + const refresher = new CodexOAuthCredentialRefresher(store, { + fetchImpl, + nowMs: () => NOW + }) + + const resolved = await refresher.resolve(SOURCE_ID) + + expect(resolved).toEqual({ + rawApiKey: store.current, + refreshable: true + }) + expect(fetchImpl).not.toHaveBeenCalled() + expect(store.updates).toHaveLength(0) + }) + + it('refreshes inside the early-invalidation window', async () => { + const store = memoryStore(encodedCredentials({ expiresAt: NOW + 4 * 60_000 })) + const { fetchImpl, tokenPosts } = tokenFetch() + const refresher = new CodexOAuthCredentialRefresher(store, { + fetchImpl, + nowMs: () => NOW + }) + + await refresher.resolve(SOURCE_ID) + + expect(tokenPosts).toHaveBeenCalledTimes(1) + expect(parseStoredCodexOAuthCredentials(store.current)?.accessToken).toBe('new-access') + }) + + it('forces a refresh for the rejected bearer and reuses an already rotated credential', async () => { + const store = memoryStore(encodedCredentials({ expiresAt: NOW + 3_600_000 })) + const { fetchImpl, tokenPosts } = tokenFetch() + const refresher = new CodexOAuthCredentialRefresher(store, { + fetchImpl, + nowMs: () => NOW + }) + + await refresher.resolve(SOURCE_ID, 'old-access') + const reused = await refresher.resolve(SOURCE_ID, 'old-access') + + expect(tokenPosts).toHaveBeenCalledTimes(1) + expect(parseStoredCodexOAuthCredentials(reused.rawApiKey)?.accessToken).toBe('new-access') + }) + + it('leaves plain API keys unchanged and non-refreshable', async () => { + const store = memoryStore('sk-plain') + const fetchImpl = vi.fn() as unknown as typeof fetch + const refresher = new CodexOAuthCredentialRefresher(store, { fetchImpl }) + + await expect(refresher.resolve('settings:provider:plain')).resolves.toEqual({ + rawApiKey: 'sk-plain', + refreshable: false + }) + expect(fetchImpl).not.toHaveBeenCalled() + expect(store.updates).toHaveLength(0) + }) + + it('preserves secrets when the endpoint omits a refresh token but redacts them from failures', async () => { + const credentials = parseStoredCodexOAuthCredentials(encodedCredentials()) + expect(credentials).not.toBeNull() + if (!credentials) return + + const refreshed = await refreshStoredCodexOAuthCredentials( + credentials, + tokenFetch({ access_token: 'new-access', expires_in: 3600 }).fetchImpl, + () => NOW + ) + expect(refreshed.refreshToken).toBe('old-refresh') + + const failingFetch = vi.fn(async () => Response.json({ + error: 'invalid_grant', + error_description: 'old-access and old-refresh were rejected' + }, { status: 400 })) as unknown as typeof fetch + await expect( + refreshStoredCodexOAuthCredentials(credentials, failingFetch, () => NOW) + ).rejects.toThrow('Codex subscription token refresh failed (400): invalid_grant: [redacted] and [redacted] were rejected') + }) + + it('recovers a real model-client 401 through the protected Codex resolver', async () => { + const store = memoryStore(encodedCredentials({ expiresAt: NOW + 3_600_000 })) + const authorization: string[] = [] + let tokenRequests = 0 + const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + if (String(input) === CODEX_OAUTH_TOKEN_ENDPOINT) { + tokenRequests += 1 + return Response.json({ + access_token: 'new-access', + refresh_token: 'new-refresh', + expires_in: 3600 + }) + } + const bearer = new Headers(init?.headers).get('authorization') ?? '' + authorization.push(bearer) + return bearer === 'Bearer old-access' + ? Response.json({ error: 'expired' }, { status: 401 }) + : Response.json({ output_text: 'ok', status: 'completed' }) + }) as unknown as typeof fetch + const refresher = new CodexOAuthCredentialRefresher(store, { + fetchImpl, + nowMs: () => NOW + }) + const client = new CompatModelClient({ + baseUrl: 'https://chatgpt.com/backend-api/codex/responses', + apiKey: 'old-access', + model: 'gpt-5.6-sol', + endpointFormat: 'custom_endpoint', + nonStreaming: true, + fetchImpl, + resolveCredentials: async (rejectedAccessToken?: string) => { + const resolved = await refresher.resolve(SOURCE_ID, rejectedAccessToken) + return { + ...materializeLegacyProviderCredential(resolved.rawApiKey), + refreshable: resolved.refreshable + } + } + }) + + const chunks = await drain(client.stream(modelRequest())) + + expect(authorization).toEqual(['Bearer old-access', 'Bearer new-access']) + expect(tokenRequests).toBe(1) + expect(parseStoredCodexOAuthCredentials(store.current)).toMatchObject({ + accessToken: 'new-access', + refreshToken: 'new-refresh' + }) + expect(chunks.at(-1)).toEqual({ kind: 'completed', stopReason: 'stop' }) + expect(chunks.some((chunk) => chunk.kind === 'error')).toBe(false) + }) +}) diff --git a/kun/src/services/codex-oauth-credential-refresher.ts b/kun/src/services/codex-oauth-credential-refresher.ts new file mode 100644 index 000000000..ac2c2ccc0 --- /dev/null +++ b/kun/src/services/codex-oauth-credential-refresher.ts @@ -0,0 +1,303 @@ +export const CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' +export const CODEX_OAUTH_TOKEN_ENDPOINT = 'https://auth.openai.com/oauth/token' +const CODEX_EARLY_INVALIDATION_MS = 5 * 60 * 1000 +const CODEX_TOKEN_TTL_FALLBACK_MS = 60 * 60 * 1000 +const CODEX_REFRESH_TIMEOUT_MS = 10_000 + +export type StoredCodexOAuthCredentials = { + kind: 'codex-oauth' + accessToken: string + refreshToken: string + expiresAt: number + accountId: string + email?: string +} + +export type CodexRefreshableCredentialStore = { + resolveApiKey(sourceId: string): Promise<{ apiKey: string } | null> + updateResolvedApiKey(sourceId: string, apiKey: string): Promise +} + +export type ResolvedCodexRequestCredential = { + rawApiKey: string + refreshable: boolean +} + +/** + * Resolves protected Codex subscription credentials immediately before model + * requests and serializes refreshes per credential source. + */ +export class CodexOAuthCredentialRefresher { + private readonly inflight = new Map>() + private readonly fetchImpl: typeof fetch + private readonly nowMs: () => number + + constructor( + private readonly store: CodexRefreshableCredentialStore, + options: { fetchImpl?: typeof fetch; nowMs?: () => number } = {} + ) { + this.fetchImpl = options.fetchImpl ?? fetch + this.nowMs = options.nowMs ?? Date.now + } + + async resolve( + sourceId: string, + rejectedAccessToken?: string + ): Promise { + let resolved = await this.store.resolveApiKey(sourceId) + if (!resolved) { + throw new Error(`protected credential source is unavailable: ${sourceId}`) + } + let credentials = parseStoredCodexOAuthCredentials(resolved.apiKey) + if (!credentials) { + return { rawApiKey: resolved.apiKey, refreshable: false } + } + + const shouldRefresh = rejectedAccessToken + ? credentials.accessToken === rejectedAccessToken + : isStoredCodexCredentialExpired(credentials, this.nowMs()) + if (shouldRefresh) { + await this.refreshSingleFlight(sourceId, rejectedAccessToken) + resolved = await this.store.resolveApiKey(sourceId) + if (!resolved) { + throw new Error(`protected credential source is unavailable after refresh: ${sourceId}`) + } + credentials = parseStoredCodexOAuthCredentials(resolved.apiKey) + if (!credentials) { + throw new Error('refreshed Codex subscription credentials are invalid') + } + } + + return { rawApiKey: resolved.apiKey, refreshable: true } + } + + private async refreshSingleFlight( + sourceId: string, + rejectedAccessToken?: string + ): Promise { + let pending = this.inflight.get(sourceId) + if (!pending) { + pending = this.refreshSource(sourceId, rejectedAccessToken) + this.inflight.set(sourceId, pending) + void pending.finally(() => { + if (this.inflight.get(sourceId) === pending) this.inflight.delete(sourceId) + }).catch(() => undefined) + } + await pending + } + + private async refreshSource( + sourceId: string, + rejectedAccessToken?: string + ): Promise { + const latest = await this.store.resolveApiKey(sourceId) + if (!latest) throw new Error(`protected credential source is unavailable: ${sourceId}`) + const credentials = parseStoredCodexOAuthCredentials(latest.apiKey) + if (!credentials) return + + if (rejectedAccessToken && credentials.accessToken !== rejectedAccessToken) return + if (!rejectedAccessToken && !isStoredCodexCredentialExpired(credentials, this.nowMs())) return + + const refreshed = await refreshStoredCodexOAuthCredentials( + credentials, + this.fetchImpl, + this.nowMs + ) + const updated = await this.store.updateResolvedApiKey( + sourceId, + JSON.stringify(refreshed) + ) + if (!updated) { + throw new Error(`protected credential source disappeared during refresh: ${sourceId}`) + } + } +} + +export function parseStoredCodexOAuthCredentials( + rawApiKey: string +): StoredCodexOAuthCredentials | null { + const value = rawApiKey.trim() + if (!value.startsWith('{')) return null + try { + const parsed = JSON.parse(value) as Record + if ( + parsed.kind !== 'codex-oauth' || + typeof parsed.accessToken !== 'string' || + !parsed.accessToken || + typeof parsed.refreshToken !== 'string' || + !parsed.refreshToken || + typeof parsed.accountId !== 'string' || + !parsed.accountId + ) return null + return { + kind: 'codex-oauth', + accessToken: parsed.accessToken, + refreshToken: parsed.refreshToken, + expiresAt: typeof parsed.expiresAt === 'number' ? parsed.expiresAt : 0, + accountId: parsed.accountId, + ...(typeof parsed.email === 'string' ? { email: parsed.email } : {}) + } + } catch { + return null + } +} + +export function isStoredCodexCredentialExpired( + credentials: StoredCodexOAuthCredentials, + nowMs: number = Date.now() +): boolean { + return !Number.isFinite(credentials.expiresAt) || + credentials.expiresAt <= 0 || + nowMs >= credentials.expiresAt - CODEX_EARLY_INVALIDATION_MS +} + +export async function refreshStoredCodexOAuthCredentials( + credentials: StoredCodexOAuthCredentials, + fetchImpl: typeof fetch = fetch, + nowMs: () => number = Date.now +): Promise { + let response: Response + try { + response = await fetchImpl(CODEX_OAUTH_TOKEN_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: credentials.refreshToken, + client_id: CODEX_OAUTH_CLIENT_ID + }).toString(), + signal: AbortSignal.timeout(CODEX_REFRESH_TIMEOUT_MS) + }) + } catch (error) { + const message = redactKnownSecrets( + error instanceof Error ? error.message : String(error), + credentials + ) + throw new Error(`Codex subscription token refresh failed${message ? `: ${message}` : ''}`) + } + + const text = await response.text() + if (!response.ok) { + const detail = summarizeAuthErrorBody(text, credentials) + throw new Error( + `Codex subscription token refresh failed (${response.status})${detail ? `: ${detail}` : ''}` + ) + } + + let tokens: Record + try { + tokens = JSON.parse(text) as Record + } catch { + throw new Error('Codex subscription token refresh returned invalid JSON') + } + const accessToken = typeof tokens.access_token === 'string' ? tokens.access_token : '' + if (!accessToken) { + throw new Error('Codex subscription token refresh returned no access token') + } + const refreshToken = typeof tokens.refresh_token === 'string' && tokens.refresh_token + ? tokens.refresh_token + : credentials.refreshToken + return { + kind: 'codex-oauth', + accessToken, + refreshToken, + expiresAt: expiresAtFromTokens(tokens, accessToken, nowMs()), + accountId: extractAccountIdFromTokens(tokens.id_token, accessToken) ?? credentials.accountId, + email: extractJwtString(tokens.id_token, accessToken, 'email') ?? credentials.email + } +} + +function expiresAtFromTokens( + tokens: Record, + accessToken: string, + nowMs: number +): number { + const expiresIn = Number(tokens.expires_in) + if (Number.isFinite(expiresIn) && expiresIn > 0) return nowMs + expiresIn * 1000 + const jwtExpiry = extractJwtNumber(accessToken, 'exp') + if (jwtExpiry && jwtExpiry * 1000 > nowMs) return jwtExpiry * 1000 + return nowMs + CODEX_TOKEN_TTL_FALLBACK_MS +} + +function extractAccountIdFromTokens( + idToken: unknown, + accessToken: string +): string | undefined { + for (const token of [typeof idToken === 'string' ? idToken : '', accessToken]) { + const claims = parseJwtClaims(token) + if (!claims) continue + if (typeof claims.chatgpt_account_id === 'string' && claims.chatgpt_account_id) { + return claims.chatgpt_account_id + } + const auth = claims['https://api.openai.com/auth'] + if (auth && typeof auth === 'object' && !Array.isArray(auth)) { + const accountId = (auth as Record).chatgpt_account_id + if (typeof accountId === 'string' && accountId) return accountId + } + const organizations = claims.organizations + if (Array.isArray(organizations)) { + const first = organizations[0] + if (first && typeof first === 'object' && !Array.isArray(first)) { + const accountId = (first as Record).id + if (typeof accountId === 'string' && accountId) return accountId + } + } + } + return undefined +} + +function extractJwtString( + idToken: unknown, + accessToken: string, + claim: string +): string | undefined { + for (const token of [typeof idToken === 'string' ? idToken : '', accessToken]) { + const value = parseJwtClaims(token)?.[claim] + if (typeof value === 'string' && value) return value + } + return undefined +} + +function extractJwtNumber(token: string, claim: string): number | undefined { + const value = parseJwtClaims(token)?.[claim] + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function parseJwtClaims(token: string): Record | undefined { + const body = token.split('.')[1] + if (!body) return undefined + try { + return JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as Record + } catch { + return undefined + } +} + +function summarizeAuthErrorBody( + text: string, + credentials: StoredCodexOAuthCredentials +): string { + let summary = '' + try { + const parsed = JSON.parse(text) as Record + summary = [ + typeof parsed.error === 'string' ? parsed.error : '', + typeof parsed.error_description === 'string' ? parsed.error_description : '', + typeof parsed.message === 'string' ? parsed.message : '' + ].filter(Boolean).join(': ') + } catch { + summary = text.replace(/\s+/g, ' ').trim() + } + return redactKnownSecrets(summary, credentials).slice(0, 300) +} + +function redactKnownSecrets( + value: string, + credentials: StoredCodexOAuthCredentials +): string { + let redacted = value + for (const secret of [credentials.accessToken, credentials.refreshToken]) { + if (secret) redacted = redacted.split(secret).join('[redacted]') + } + return redacted +} diff --git a/kun/src/services/grok-oauth-credential-refresher.ts b/kun/src/services/grok-oauth-credential-refresher.ts index 5d62f1fca..25442e4e1 100644 --- a/kun/src/services/grok-oauth-credential-refresher.ts +++ b/kun/src/services/grok-oauth-credential-refresher.ts @@ -1,6 +1,8 @@ +import { GROK_CLI_VERSION } from '../adapters/model/provider-cli-identity.js' + export const GROK_OAUTH_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828' export const GROK_OAUTH_ISSUER = 'https://auth.x.ai' -export const GROK_CLIENT_VERSION = '0.2.106' +export const GROK_CLIENT_VERSION = GROK_CLI_VERSION const GROK_EARLY_INVALIDATION_MS = 5 * 60 * 1000 const GROK_TOKEN_TTL_FALLBACK_MS = 30 * 24 * 60 * 60 * 1000 const GROK_REFRESH_TIMEOUT_MS = 10_000 diff --git a/kun/src/services/legacy-provider-credential-migration.test.ts b/kun/src/services/legacy-provider-credential-migration.test.ts index 76d6755ce..bd675ef47 100644 --- a/kun/src/services/legacy-provider-credential-migration.test.ts +++ b/kun/src/services/legacy-provider-credential-migration.test.ts @@ -211,8 +211,12 @@ describe('materializeLegacyProviderCredential', () => { expect(material.apiKey).toBe('codex-access') expect(material.headers).toMatchObject({ 'ChatGPT-Account-Id': 'acct_1', - originator: 'codex_cli_rs' + originator: 'codex_cli_rs', + 'OpenAI-Beta': 'responses=experimental' }) + expect(material.headers?.['User-Agent']).toMatch(/^codex_cli_rs\/0\.145\.0 \(.+; .+\)$/) + expect(material.headers?.['User-Agent']).not.toMatch(/deepseekgui|kun/i) + expect(material.headers?.['x-grok-client-identifier']).toBeUndefined() }) it('unwraps Grok OAuth credentials into access token + cli-chat-proxy headers', () => { @@ -228,7 +232,7 @@ describe('materializeLegacyProviderCredential', () => { headers: { 'X-XAI-Token-Auth': 'xai-grok-cli', 'x-authenticateresponse': 'authenticate-response', - 'x-grok-client-version': '0.2.106', + 'x-grok-client-version': '0.2.112', 'x-grok-client-mode': 'interactive' } }) diff --git a/kun/src/services/legacy-provider-credential-migration.ts b/kun/src/services/legacy-provider-credential-migration.ts index e8cf2569e..9f942b43a 100644 --- a/kun/src/services/legacy-provider-credential-migration.ts +++ b/kun/src/services/legacy-provider-credential-migration.ts @@ -1,11 +1,17 @@ import { randomBytes, randomUUID, scryptSync } from 'node:crypto' import { join } from 'node:path' import { z } from 'zod' +import { + codexCliRequestHeaders, + codexCliUserAgent, + grokCliProxyHeaders +} from '../adapters/model/provider-cli-identity.js' import { AtomicJsonFile } from '../extensions/atomic-json.js' import type { ExtensionPrincipal } from './extension-agent-service.js' import type { ExtensionCredentialStore } from './extension-credential-store.js' import type { ExtensionProviderAccountStore } from './extension-provider-account-store.js' -import { GROK_CLIENT_VERSION } from './grok-oauth-credential-refresher.js' + +export { codexCliUserAgent } const MigrationRollbackSchema = z.object({ accountId: z.string().min(1), @@ -87,25 +93,17 @@ export function materializeLegacyProviderCredential(rawApiKey: string): LegacyPr if (!accessToken || !accountId) return { apiKey } return { apiKey: accessToken, - headers: { - 'ChatGPT-Account-Id': accountId, - originator: 'codex_cli_rs', - 'OpenAI-Beta': 'responses=experimental', - 'User-Agent': 'codex_cli_rs/0.0.0 (deepseekgui)', - session_id: randomUUID() - } + headers: codexCliRequestHeaders({ + accountId, + sessionId: randomUUID() + }) } } if (parsed.kind === 'grok-oauth') { if (!accessToken) return { apiKey } return { apiKey: accessToken, - headers: { - 'X-XAI-Token-Auth': 'xai-grok-cli', - 'x-authenticateresponse': 'authenticate-response', - 'x-grok-client-version': GROK_CLIENT_VERSION, - 'x-grok-client-mode': 'interactive' - } + headers: grokCliProxyHeaders() } } return { apiKey } diff --git a/kun/src/services/llm-debug-recorder.ts b/kun/src/services/llm-debug-recorder.ts index 0f41b1d35..c4986bbc7 100644 --- a/kun/src/services/llm-debug-recorder.ts +++ b/kun/src/services/llm-debug-recorder.ts @@ -7,6 +7,7 @@ import { MAX_MODEL_REQUEST_TRACE_TOOL_NAME_LENGTH, MODEL_REQUEST_TRACE_SCHEMA_VERSION, type ModelRequestTraceDecoded, + type ModelRequestTraceDelegated, type ModelRequestTraceLimits, type ModelRequestTracePage, type ModelRequestTraceRecord, @@ -49,8 +50,15 @@ export type LlmDebugToolCall = { arguments: Record } +export type LlmDebugToolResult = { + callId: string + toolName: string + output: string + isError: boolean +} + export type LlmDebugOutputTruncation = Partial> @@ -58,6 +66,7 @@ export type LlmDebugOutput = { text: string reasoning: string toolCalls: LlmDebugToolCall[] + toolResults: LlmDebugToolResult[] usage?: UsageSnapshot stopReason?: string error?: string @@ -88,6 +97,7 @@ export type LlmCliInvocationMeta = { endpointFormat: string target: string bodyText: string + delegated?: ModelRequestTraceDelegated } export type LlmSdkInvocationMeta = { @@ -95,6 +105,7 @@ export type LlmSdkInvocationMeta = { target: string bodyText: string secretValues?: readonly string[] + delegated?: ModelRequestTraceDelegated } /** Narrow sink used by model clients to retain bounded debug data. */ @@ -107,6 +118,7 @@ export interface LlmDebugSink { captureHttpError(record: ModelRequestTraceRecord, error: unknown): void captureTransportError(record: ModelRequestTraceRecord, error: unknown): void captureChunk(round: LlmDebugRound, chunk: ModelStreamChunk): void + captureToolResult?(round: LlmDebugRound, result: LlmDebugToolResult): void finish(round: LlmDebugRound): Promise } @@ -192,7 +204,7 @@ export class LlmDebugRecorder implements LlmDebugSink { finishedAt: startedAt, durationMs: 0, requestBody: null, - output: { text: '', reasoning: '', toolCalls: [] }, + output: { text: '', reasoning: '', toolCalls: [], toolResults: [] }, exchanges: [] } this.states.set(round, createCaptureState(meta.toolCatalog)) @@ -226,7 +238,8 @@ export class LlmDebugRecorder implements LlmDebugSink { reason: 'initial', target: meta.target, headers: {}, - bodyText: meta.bodyText + bodyText: meta.bodyText, + ...(meta.delegated ? { delegated: meta.delegated } : {}) }) } @@ -240,7 +253,8 @@ export class LlmDebugRecorder implements LlmDebugSink { target: meta.target, headers: {}, bodyText: meta.bodyText, - ...(meta.secretValues ? { secretValues: meta.secretValues } : {}) + ...(meta.secretValues ? { secretValues: meta.secretValues } : {}), + ...(meta.delegated ? { delegated: meta.delegated } : {}) }) } @@ -256,6 +270,7 @@ export class LlmDebugRecorder implements LlmDebugSink { headers: Record bodyText: string secretValues?: readonly string[] + delegated?: ModelRequestTraceDelegated } ): ModelRequestTraceRecord { const state = this.stateFor(round) @@ -284,7 +299,15 @@ export class LlmDebugRecorder implements LlmDebugSink { urlRedacted: sanitizedUrl.redacted, headers: sanitizeModelTraceHeaders(meta.headers, meta.secretValues), body - } + }, + ...(meta.delegated + ? { + delegated: { + ...meta.delegated, + capabilities: { ...meta.delegated.capabilities } + } + } + : {}) } round.exchanges.push(record) round.url = sanitizedUrl.value @@ -356,6 +379,28 @@ export class LlmDebugRecorder implements LlmDebugSink { } } + captureToolResult(round: LlmDebugRound, result: LlmDebugToolResult): void { + const state = this.stateFor(round) + const base = { + callId: result.callId, + toolName: result.toolName, + output: '', + isError: result.isError + } + const available = Math.max(0, this.remainingOutputBytes(state) - jsonBytes(base)) + const output = truncateJsonStringContent(result.output, available) + const retained = { ...base, output } + const bytes = jsonBytes(retained) + if (bytes <= this.remainingOutputBytes(state)) { + round.output.toolResults.push(retained) + state.outputBytes += bytes + } else { + markTruncated(round.output, 'toolResults') + return + } + if (output !== result.output) markTruncated(round.output, 'toolResults') + } + async finish(round: LlmDebugRound): Promise { const state = this.stateFor(round) await Promise.allSettled(state.pendingCaptures) @@ -630,6 +675,9 @@ function cloneDecoded(output: LlmDebugOutput): ModelRequestTraceDecoded { text: output.text, reasoning: output.reasoning, toolCalls: output.toolCalls.map((call) => ({ ...call, arguments: { ...call.arguments } })), + ...(output.toolResults.length + ? { toolResults: output.toolResults.map((result) => ({ ...result })) } + : {}), ...(output.usage ? { usage: { ...output.usage } } : {}), ...(output.stopReason ? { stopReason: output.stopReason } : {}), ...(output.error ? { error: output.error } : {}), diff --git a/kun/src/services/review-service.ts b/kun/src/services/review-service.ts index 5298b8f55..f82dc26c7 100644 --- a/kun/src/services/review-service.ts +++ b/kun/src/services/review-service.ts @@ -13,7 +13,11 @@ import type { ReviewTarget } from '../contracts/review.js' import { AgentLoop } from '../loop/agent-loop.js' import { ContextCompactor } from '../loop/context-compactor.js' import { InflightTracker } from '../loop/inflight-tracker.js' -import type { ContextCompactionConfig, ModelConfig } from '../loop/model-context-profile.js' +import type { + ContextCompactionConfig, + ModelConfig, + ModelContextProfile +} from '../loop/model-context-profile.js' import { modelCapabilitiesForModel } from '../loop/model-context-profile.js' import { SteeringQueue } from '../loop/steering-queue.js' import type { TokenEconomyConfig } from '../loop/token-economy.js' @@ -39,7 +43,10 @@ export type ReviewServiceDeps = { contextCompaction?: ContextCompactionConfig tokenEconomy?: TokenEconomyConfig runtime?: RuntimeTuningConfig - modelCapabilities?: (model: string) => ModelCapabilityMetadata + modelCapabilities?: (model: string, providerId?: string) => ModelCapabilityMetadata + profilesForProvider?: ( + providerId: string | undefined + ) => readonly ModelContextProfile[] /** Reasoning depth for the code-review model call. Invalid/missing => 'off'. */ reasoningEffort?: string roleModel?: string @@ -148,7 +155,8 @@ export class ReviewService { const steering = new SteeringQueue() const compactor = new ContextCompactor({ contextCompaction: this.deps.contextCompaction, - models: this.deps.models + models: this.deps.models, + profilesForProvider: this.deps.profilesForProvider }) const events = new RuntimeEventRecorder({ eventBus, @@ -196,7 +204,7 @@ export class ReviewService { ids, nowIso, modelCapabilities: (model) => - this.deps.modelCapabilities?.(model) ?? modelCapabilitiesForModel(model), + this.deps.modelCapabilities?.(model, input.providerId) ?? modelCapabilitiesForModel(model), ...(this.deps.contextCompaction ? { contextCompaction: this.deps.contextCompaction } : {}), ...(this.deps.tokenEconomy ? { tokenEconomy: this.deps.tokenEconomy } : {}), ...(this.deps.runtime?.toolStorm ? { toolStorm: this.deps.runtime.toolStorm } : {}), diff --git a/kun/src/services/runtime-migration-import-service.test.ts b/kun/src/services/runtime-migration-import-service.test.ts index e14f16879..ad1ae3d9a 100644 --- a/kun/src/services/runtime-migration-import-service.test.ts +++ b/kun/src/services/runtime-migration-import-service.test.ts @@ -27,15 +27,26 @@ async function harness() { const threadStore = new InMemoryThreadStore() const sessionStore = new InMemorySessionStore() const maintenance = new ScopedMigrationMaintenanceLock() + const importedThreadIds: string[] = [] const service = new RuntimeMigrationImportService({ rootDir: join(root, 'imports'), threadStore, sessionStore, maintenance, attachmentStore: () => undefined, - memoryStore: () => undefined + memoryStore: () => undefined, + onThreadImported: async (threadId) => { + importedThreadIds.push(threadId) + } }) - return { service, threadStore, sessionStore, maintenance, importRoot: join(root, 'imports') } + return { + service, + threadStore, + sessionStore, + maintenance, + importedThreadIds, + importRoot: join(root, 'imports') + } } function control(configuredProviderIds: string[] = []) { @@ -81,6 +92,7 @@ describe('RuntimeMigrationImportService', () => { const committed = await h.service.commit(preflight.importId) expect(committed.status).toBe('committed') + expect(h.importedThreadIds).toEqual(['thread_source']) const imported = await h.threadStore.get('thread_source') expect(imported?.workspace).toBe('/Users/bob/Project') expect(imported?.providerId).toBeUndefined() @@ -103,6 +115,7 @@ describe('RuntimeMigrationImportService', () => { expect(preflight.introducedThreadIds).toEqual([]) const committed = await h.service.commit(preflight.importId) expect(committed.introducedThreadIds).toEqual([]) + expect(h.importedThreadIds).toEqual([]) expect((await h.threadStore.get('thread_source'))?.providerId).toBe('historical-provider') }) diff --git a/kun/src/services/runtime-migration-import-service.ts b/kun/src/services/runtime-migration-import-service.ts index 204fee9cc..1d4ab7f9d 100644 --- a/kun/src/services/runtime-migration-import-service.ts +++ b/kun/src/services/runtime-migration-import-service.ts @@ -85,6 +85,8 @@ export class RuntimeMigrationImportService { attachmentStore: () => AttachmentStore | undefined artifactStore?: ArtifactStore memoryStore: () => MemoryStore | undefined + /** Imported threads must not inherit machine-local provider checkpoints. */ + onThreadImported?: (threadId: string) => Promise }) { this.rootDir = resolve(deps.rootDir) } @@ -236,6 +238,7 @@ export class RuntimeMigrationImportService { await this.persistState(state) const existing = await this.deps.threadStore.get(targetThreadId) if (!existing) { + await this.deps.onThreadImported?.(targetThreadId) await this.deps.threadStore.upsert(thread) increment(state.counts, 'threads') } else if (canonicalLine('thread', sanitizeMigrationValue(existing)) !== canonicalLine('thread', sanitizeMigrationValue(thread))) { diff --git a/kun/src/services/turn-service.test.ts b/kun/src/services/turn-service.test.ts index e4d2b0a9b..28c63669a 100644 --- a/kun/src/services/turn-service.test.ts +++ b/kun/src/services/turn-service.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { createImmutablePrefix } from '../cache/immutable-prefix.js' import { InMemoryEventBus } from '../adapters/in-memory-event-bus.js' import { InMemorySessionStore } from '../adapters/in-memory-session-store.js' @@ -24,6 +27,19 @@ import { } from './turn-service.js' import { ThreadService } from './thread-service.js' import { UsageService } from './usage-service.js' +import { FileAttachmentStore } from '../attachments/attachment-store.js' +import { KunCapabilitiesConfig } from '../contracts/capabilities.js' + +function testPng(): Buffer { + const buffer = Buffer.alloc(24) + buffer[0] = 0x89 + buffer[1] = 0x50 + buffer[2] = 0x4e + buffer[3] = 0x47 + buffer.writeUInt32BE(1, 16) + buffer.writeUInt32BE(1, 20) + return buffer +} class SummaryModel implements ModelClient { readonly provider = 'test' @@ -103,6 +119,225 @@ describe('TurnService startTurn', () => { expect(DEFAULT_MAX_CONCURRENT_TURNS).toBe(256) }) + it('binds submitted attachments to the final thread before persisting the turn', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-turn-attachment-')) + try { + const sessionStore = new InMemorySessionStore() + const threadStore = new InMemoryThreadStore() + const eventBus = new InMemoryEventBus() + const nowIso = () => '2026-07-24T00:00:00.000Z' + const attachmentStore = new FileAttachmentStore({ + rootDir: join(root, 'attachments'), + config: KunCapabilitiesConfig.parse({ attachments: { enabled: true } }).attachments, + nowIso + }) + const service = new TurnService({ + threadStore, + sessionStore, + events: new RuntimeEventRecorder({ + eventBus, + sessionStore, + allocateSeq: (threadId) => eventBus.allocateSeq(threadId), + nowIso + }), + inflight: new InflightTracker(), + steering: new SteeringQueue(), + compactor: new ContextCompactor(), + attachmentStore: () => attachmentStore, + ids: new SequentialIdGenerator(), + nowIso + }) + const threadId = 'thr_attachment_final' + await threadStore.upsert(createThreadRecord({ + id: threadId, + title: 'Attachment turn', + workspace: '/tmp/workspace', + model: 'deepseek-v4-pro' + })) + const attachment = await attachmentStore.create({ + name: 'draft.png', + data: testPng(), + workspace: '/tmp/workspace' + }) + + const started = await service.startTurn({ + threadId, + request: { prompt: 'inspect', model: 'm', attachmentIds: [attachment.id, attachment.id] } + }) + + await expect(attachmentStore.resolveContent(attachment.id, { threadId })).resolves.toMatchObject({ + id: attachment.id + }) + expect((await threadStore.get(threadId))?.turns[0]?.attachmentIds).toEqual([attachment.id]) + expect((await sessionStore.loadItems(threadId))[0]).toMatchObject({ + attachmentIds: [attachment.id] + }) + await service.interruptTurn({ threadId, turnId: started.turnId }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('does not persist a turn when a submitted attachment is missing', async () => { + const sessionStore = new InMemorySessionStore() + const threadStore = new InMemoryThreadStore() + const eventBus = new InMemoryEventBus() + const nowIso = () => '2026-07-24T00:00:00.000Z' + const bindScopes = async (): Promise => { + throw new Error('attachment not found: att_000000000000000000000000') + } + const service = new TurnService({ + threadStore, + sessionStore, + events: new RuntimeEventRecorder({ + eventBus, + sessionStore, + allocateSeq: (threadId) => eventBus.allocateSeq(threadId), + nowIso + }), + inflight: new InflightTracker(), + steering: new SteeringQueue(), + compactor: new ContextCompactor(), + attachmentStore: () => ({ bindScopes } as never), + ids: new SequentialIdGenerator(), + nowIso + }) + const threadId = 'thr_attachment_missing' + await threadStore.upsert(createThreadRecord({ + id: threadId, + title: 'Missing attachment', + workspace: '/tmp/workspace', + model: 'deepseek-v4-pro' + })) + + await expect(service.startTurn({ + threadId, + request: { + prompt: 'inspect', + model: 'm', + attachmentIds: ['att_000000000000000000000000'] + } + })).rejects.toThrow(/attachment not found/) + + expect((await threadStore.get(threadId))?.turns).toEqual([]) + expect(await sessionStore.loadItems(threadId)).toEqual([]) + }) + + it('does not bind any attachment when batch validation fails', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-turn-attachment-batch-')) + try { + const sessionStore = new InMemorySessionStore() + const threadStore = new InMemoryThreadStore() + const eventBus = new InMemoryEventBus() + const nowIso = () => '2026-07-24T00:00:00.000Z' + const attachmentStore = new FileAttachmentStore({ + rootDir: join(root, 'attachments'), + config: KunCapabilitiesConfig.parse({ attachments: { enabled: true } }).attachments, + nowIso + }) + const service = new TurnService({ + threadStore, + sessionStore, + events: new RuntimeEventRecorder({ + eventBus, + sessionStore, + allocateSeq: (threadId) => eventBus.allocateSeq(threadId), + nowIso + }), + inflight: new InflightTracker(), + steering: new SteeringQueue(), + compactor: new ContextCompactor(), + attachmentStore: () => attachmentStore, + ids: new SequentialIdGenerator(), + nowIso + }) + const threadId = 'thr_attachment_batch_failure' + await threadStore.upsert(createThreadRecord({ + id: threadId, + title: 'Attachment batch failure', + workspace: '/tmp/workspace', + model: 'deepseek-v4-pro' + })) + const valid = await attachmentStore.create({ + name: 'valid.png', + data: testPng(), + workspace: '/tmp/workspace' + }) + + await expect(service.startTurn({ + threadId, + request: { + prompt: 'inspect', + model: 'm', + attachmentIds: [valid.id, 'att_000000000000000000000000'] + } + })).rejects.toThrow(/attachment not found/) + + expect(await attachmentStore.get(valid.id)).toMatchObject({ threadIds: [] }) + expect((await threadStore.get(threadId))?.turns).toEqual([]) + expect(await sessionStore.loadItems(threadId)).toEqual([]) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('preserves every thread scope when turns bind the same attachment concurrently', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-turn-attachment-concurrent-')) + try { + const sessionStore = new InMemorySessionStore() + const threadStore = new InMemoryThreadStore() + const eventBus = new InMemoryEventBus() + const nowIso = () => '2026-07-24T00:00:00.000Z' + const attachmentStore = new FileAttachmentStore({ + rootDir: join(root, 'attachments'), + config: KunCapabilitiesConfig.parse({ attachments: { enabled: true } }).attachments, + nowIso + }) + const service = new TurnService({ + threadStore, + sessionStore, + events: new RuntimeEventRecorder({ + eventBus, + sessionStore, + allocateSeq: (threadId) => eventBus.allocateSeq(threadId), + nowIso + }), + inflight: new InflightTracker(), + steering: new SteeringQueue(), + compactor: new ContextCompactor(), + attachmentStore: () => attachmentStore, + ids: new SequentialIdGenerator(), + nowIso + }) + const threadIds = ['thr_attachment_concurrent_a', 'thr_attachment_concurrent_b'] + for (const threadId of threadIds) { + await threadStore.upsert(createThreadRecord({ + id: threadId, + title: threadId, + workspace: '/tmp/shared-workspace', + model: 'deepseek-v4-pro' + })) + } + const attachment = await attachmentStore.create({ + name: 'shared.png', + data: testPng(), + workspace: '/tmp/shared-workspace' + }) + + const starts = await Promise.all(threadIds.map((threadId) => service.startTurn({ + threadId, + request: { prompt: 'inspect', model: 'm', attachmentIds: [attachment.id] } + }))) + + expect((await attachmentStore.get(attachment.id))?.threadIds.sort()).toEqual([...threadIds].sort()) + await Promise.all(starts.map((started, index) => + service.interruptTurn({ threadId: threadIds[index], turnId: started.turnId }) + )) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it('atomically admits only one active turn for a thread', async () => { const sessionStore = new InMemorySessionStore() const threadStore = new InMemoryThreadStore() @@ -566,6 +801,7 @@ describe('TurnService compact', () => { nowIso }) const model = new SummaryModel() + const compactedThreads: string[] = [] const prefix = createImmutablePrefix({ systemPrompt: 'System prompt used by both chat and compaction.', pinnedConstraints: ['system: keep GUI HTTP/SSE stable'] @@ -587,6 +823,9 @@ describe('TurnService compact', () => { summaryMaxTokens: 400, summaryInputMaxBytes: 16_384 }, + onCompacted: async (threadId) => { + compactedThreads.push(threadId) + }, ids: new SequentialIdGenerator(), nowIso }) @@ -658,6 +897,7 @@ describe('TurnService compact', () => { expect(continuationItem.text).not.toContain('Active Skill: retained-manual-tail-only') expect(response.summary).toContain('MODEL SUMMARY kept the durable state.') expect(response.pinnedConstraints).toEqual(prefix.pinnedConstraints) + expect(compactedThreads).toEqual([threadId]) const visibleItems = await sessionStore.loadItems(threadId) expect(visibleItems).toHaveLength(7) diff --git a/kun/src/services/turn-service.ts b/kun/src/services/turn-service.ts index a93a6a875..85f3fe1df 100644 --- a/kun/src/services/turn-service.ts +++ b/kun/src/services/turn-service.ts @@ -16,6 +16,7 @@ import type { MigrationMaintenanceLock } from '../ports/migration-maintenance-lo import type { IdGenerator } from '../ports/id-generator.js' import type { ModelClient } from '../ports/model-client.js' import type { ImmutablePrefix } from '../cache/immutable-prefix.js' +import type { AttachmentStore } from '../attachments/attachment-store.js' import type { InflightTracker } from '../loop/inflight-tracker.js' import type { SteeringQueue } from '../loop/steering-queue.js' import { ContextCompactor, extractSkillPins } from '../loop/context-compactor.js' @@ -52,6 +53,7 @@ export type TurnServiceDeps = { model?: ModelClient usage?: UsageService prefix?: ImmutablePrefix + attachmentStore?: () => AttachmentStore | undefined defaultModel?: string contextCompaction?: ContextCompactionConfig /** Maximum number of active turns this in-process runtime may admit. */ @@ -59,6 +61,8 @@ export type TurnServiceDeps = { /** Reject turn admission while this thread is being destructively removed. */ lifecycleFence?: ThreadLifecycleFence migrationMaintenance?: MigrationMaintenanceLock + /** Dispose machine-local continuation state after a successful manual compaction. */ + onCompacted?: (threadId: string) => Promise ids: IdGenerator nowIso: () => string } @@ -169,6 +173,17 @@ export class TurnService { const composerContexts = ComposerContextAttachmentSchema.array().parse( input.request.composerContexts ?? [] ) + const attachmentIds = [...new Set( + (input.request.attachmentIds ?? []).map((id) => id.trim()).filter(Boolean) + )] + if (attachmentIds.length > 0) { + const attachmentStore = this.deps.attachmentStore?.() + if (!attachmentStore) throw new Error('attachment store is unavailable') + await attachmentStore.bindScopes(attachmentIds, { + threadId: input.threadId, + ...(thread.workspace ? { workspace: thread.workspace } : {}) + }) + } const turn = createTurnRecord({ id: turnId, threadId: input.threadId, @@ -177,7 +192,7 @@ export class TurnService { providerId: input.request.providerId, accountId: input.request.accountId, reasoningEffort: input.request.reasoningEffort, - attachmentIds: input.request.attachmentIds ?? [], + attachmentIds, composerContexts, guiPlan: input.request.guiPlan, guiDesignCanvas: input.request.guiDesignCanvas, @@ -199,7 +214,7 @@ export class TurnService { text: input.request.prompt, displayText: input.request.displayText, messageSource: input.request.messageSource, - attachmentIds: input.request.attachmentIds ?? [], + attachmentIds, composerContexts, fileReferences: input.request.fileReferences ?? [], workspaceCheckpointId: input.request.workspaceCheckpointId @@ -610,6 +625,7 @@ export class TurnService { ? { sourceItemIds: result.summaryItem.sourceItemIds } : {}) }) + await this.deps.onCompacted?.(input.threadId) } return { threadId: input.threadId, diff --git a/kun/tests/attachment-store.test.ts b/kun/tests/attachment-store.test.ts index 905be8027..60d215816 100644 --- a/kun/tests/attachment-store.test.ts +++ b/kun/tests/attachment-store.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { mkdtemp, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -60,6 +61,51 @@ describe('Attachment store and multimodal input', () => { await expect(store.resolveContent(first.id, { workspace: '/tmp/ws' })).resolves.toMatchObject({ id: first.id }) }) + it('binds an authorized attachment to its final thread idempotently', async () => { + const store = createStore() + const attachment = await store.create({ + name: 'draft.png', + data: png(2, 3), + workspace: '/tmp/ws' + }) + + await store.bindScope(attachment.id, { threadId: 'thr_final', workspace: '/tmp/ws' }) + await store.bindScope(attachment.id, { threadId: 'thr_final', workspace: '/tmp/ws' }) + + expect(await store.get(attachment.id)).toMatchObject({ + threadIds: ['thr_final'], + workspaces: ['/tmp/ws'] + }) + await expect(store.resolveContent(attachment.id, { threadId: 'thr_final' })) + .resolves.toMatchObject({ id: attachment.id }) + }) + + it('does not bind an attachment from an unrelated scope', async () => { + const store = createStore() + const attachment = await store.create({ + name: 'private.png', + data: png(2, 3), + threadId: 'thr_owner', + workspace: '/tmp/owner' + }) + + await expect(store.bindScope(attachment.id, { + threadId: 'thr_attacker', + workspace: '/tmp/other' + })).rejects.toThrow(/not authorized/) + expect(await store.get(attachment.id)).toMatchObject({ + threadIds: ['thr_owner'], + workspaces: ['/tmp/owner'] + }) + }) + + it('rejects invalid or missing attachment ids when binding scope', async () => { + const store = createStore() + await expect(store.bindScope('../outside', { threadId: 'thr_1' })).rejects.toThrow(/invalid attachment id/) + await expect(store.bindScope('att_000000000000000000000000', { threadId: 'thr_1' })) + .rejects.toThrow(/attachment not found/) + }) + it('keeps attachment data and metadata private on disk', async () => { const store = createStore() const attachment = await store.create({ name: 'shot.png', data: png(2, 3), threadId: 'thr_1' }) @@ -106,6 +152,13 @@ describe('Attachment store and multimodal input', () => { mimeType: 'application/octet-stream' })).rejects.toThrow(/unsupported/) + await expect(createStore().create({ + name: 'spoofed.xlsx', + data: Buffer.from('not a zip package'), + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + documentText: 'fake' + })).rejects.toThrow(/unsupported/) + await expect(createStore({ maxImageBytes: 10 }).create({ name: 'large.png', data: png(1, 1) @@ -129,6 +182,69 @@ describe('Attachment store and multimodal input', () => { })).rejects.toThrow(/fallback image exceeds/) }) + it('stores Office semantics and visual previews while verifying the declared source hash', async () => { + const store = createStore() + const data = Buffer.from('PK\u0003\u0004 workbook fixture') + const sourceSha256 = createHash('sha256').update(data).digest('hex') + const preview = { + dataBase64: Buffer.from('preview').toString('base64'), + mimeType: 'image/webp', + byteSize: 7, + width: 800, + height: 600, + wasCompressed: true + } as const + + const attachment = await store.create({ + name: 'book.xlsx', + data, + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + documentText: 'Sheet1\nA1 = 42', + documentFormat: 'xlsx', + sourceSha256, + visualPreview: preview, + threadId: 'thr_office' + }) + + expect(attachment).toMatchObject({ + kind: 'document', + documentFormat: 'xlsx', + sourceSha256, + documentText: 'Sheet1\nA1 = 42', + visualPreview: preview + }) + await expect(store.create({ + name: 'book.xlsx', + data, + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + documentText: 'Sheet1', + documentFormat: 'xlsx', + sourceSha256: '0'.repeat(64) + })).rejects.toThrow(/source SHA-256/) + }) + + it('decodes UTF-16 BOM text documents without treating their NUL bytes as binary', async () => { + const store = createStore() + const text = '编号\t金额\n1\t42' + const data = Buffer.concat([ + Buffer.from([0xff, 0xfe]), + Buffer.from(text, 'utf16le') + ]) + + const attachment = await store.create({ + name: 'data.tsv', + data, + mimeType: 'text/tab-separated-values', + documentFormat: 'text', + threadId: 'thr_text' + }) + + expect(attachment).toMatchObject({ + kind: 'document', + documentText: text + }) + }) + it('serves authenticated upload, metadata, content, and diagnostics routes', async () => { const h = buildHarness() h.runtime.attachmentStore = createStore() diff --git a/kun/tests/auto-title-gate.test.ts b/kun/tests/auto-title-gate.test.ts index 6a171ff58..3d5458828 100644 --- a/kun/tests/auto-title-gate.test.ts +++ b/kun/tests/auto-title-gate.test.ts @@ -3,10 +3,10 @@ import { canUpgradeThreadTitle } from '../src/loop/agent-loop.js' /** * Guards the "placeholder → LLM-summary upgrade" titling contract: the backend - * LLM titler may overwrite a placeholder or an explicitly-provisional title, - * but must never clobber a user-renamed (locked) one. Regression coverage for - * the bug where the renderer's eager first-message rename pre-empted the - * backend titler. + * LLM titler (fired in parallel at first-turn start) may overwrite a + * placeholder or an explicitly-provisional title, but must never clobber a + * user-renamed (locked) one. Regression coverage for the bug where the + * renderer's eager first-message rename pre-empted the backend titler. */ describe('canUpgradeThreadTitle', () => { it('upgrades placeholder titles when titleAuto is absent (legacy)', () => { diff --git a/kun/tests/contracts.test.ts b/kun/tests/contracts.test.ts index c5ea65700..850df88bd 100644 --- a/kun/tests/contracts.test.ts +++ b/kun/tests/contracts.test.ts @@ -168,6 +168,40 @@ describe('contracts', () => { }) }) + it('accepts request-local context snapshot events', () => { + const event = RuntimeEvent.parse({ + kind: 'context_snapshot', + seq: 2, + timestamp: '2026-07-24T00:00:01.000Z', + threadId: 'thr_1', + turnId: 'turn_1', + model: 'deepseek-v4-pro', + providerId: 'deepseek', + stepIndex: 1, + contextWindowTokens: 256_000, + softThresholdTokens: 192_000, + hardThresholdTokens: 217_600, + estimatedInputTokens: 12_000, + breakdown: { + tools: 3_000, + system: 2_000, + skills: 1_000, + messages: 5_000, + other: 1_000 + }, + toolCount: 21, + activeSkillIds: ['openspec-apply-change'] + }) + + expect(event).toMatchObject({ + kind: 'context_snapshot', + model: 'deepseek-v4-pro', + stepIndex: 1, + softThresholdTokens: 192_000, + breakdown: { tools: 3_000, messages: 5_000 } + }) + }) + it('accepts GUI plan context on start turn payloads', () => { const parsed = StartTurnRequest.parse({ prompt: 'Plan auth', diff --git a/kun/tests/delegation-runtime.test.ts b/kun/tests/delegation-runtime.test.ts index 1225ecb39..4bad7de07 100644 --- a/kun/tests/delegation-runtime.test.ts +++ b/kun/tests/delegation-runtime.test.ts @@ -366,7 +366,7 @@ describe('DelegationRuntime', () => { expect(seen.at(-1)?.toolPolicy).toBe('readOnly') }) - it('lets the parent create an ephemeral custom subagent that inherits the active turn model', async () => { + it('lets custom-only mode create an ephemeral subagent that inherits the active turn model', async () => { const seen: Array<{ systemPrompt?: string blockedTools?: string[] @@ -567,7 +567,7 @@ describe('DelegationRuntime', () => { }) }) - it('rejects profile plus custom_agent before consuming a child-run slot', async () => { + it('rejects custom_agent in existing-profile mode before consuming a child-run slot', async () => { const runtime = createRuntime({ profiles: { general: { toolPolicy: 'inherit' } } }) const host = new LocalToolHost({ registry: new CapabilityRegistry(buildDelegationToolProviders(runtime)) @@ -593,7 +593,13 @@ describe('DelegationRuntime', () => { awaitApproval: async () => 'allow' }) - expect(result.item).toMatchObject({ kind: 'tool_result', isError: true }) + expect(result.item).toMatchObject({ + kind: 'tool_result', + isError: true, + output: { + error: expect.stringContaining('custom_agent is unavailable') + } + }) expect((await runtime.diagnostics('thr_conflict')).childRuns).toEqual([]) }) @@ -608,7 +614,10 @@ describe('DelegationRuntime', () => { expect(properties).not.toHaveProperty('tokenBudget') expect(properties).not.toHaveProperty('timeBudgetMs') expect(properties).not.toHaveProperty('skill_id') - expect(tools.map((candidate) => candidate.name)).toEqual(['delegate_task']) + expect(tools.map((candidate) => candidate.name)).toEqual([ + 'delegate_task', + 'list_subagent_profiles' + ]) }) it('keeps built-in specialists searchable without embedding the full roster in the tool schema', () => { @@ -624,7 +633,7 @@ describe('DelegationRuntime', () => { 'security-auditor', 'web-performance-auditor' ])) - expect(tool?.description).toContain('existing agent profiles') + expect(tool?.description).toContain('reusable profile id') expect(tool?.description).not.toContain('Senior code reviewer') }) @@ -632,7 +641,17 @@ describe('DelegationRuntime', () => { const runtime = createRuntime({ profiles: { reviewer: { description: 'Configured reviewer', toolPolicy: 'readOnly' }, - primary: { description: 'Primary only', mode: 'primary', toolPolicy: 'inherit' } + primary: { description: 'Primary only', mode: 'primary', toolPolicy: 'inherit' }, + 'code-only': { + description: 'Code-only implementation role', + surfaces: ['code'], + toolPolicy: 'inherit' + }, + 'design-only': { + description: 'Design-only implementation role', + surfaces: ['design'], + toolPolicy: 'inherit' + } } }) const agentDir = join(dir, '.kun', 'agents') @@ -689,6 +708,57 @@ describe('DelegationRuntime', () => { expect(workspaceProfile?.profile.model).toBeUndefined() expect(workspaceProfile?.profile.providerId).toBeUndefined() + const host = new LocalToolHost({ + registry: new CapabilityRegistry(buildDelegationToolProviders(runtime)) + }) + const discovered = await host.execute({ + callId: 'call_list_profiles', + toolName: 'list_subagent_profiles', + arguments: {} + }, { + threadId: 'thr_list_profiles', + turnId: 'turn_list_profiles', + workspace: dir, + agentSurface: 'design', + approvalPolicy: 'auto', + abortSignal: new AbortController().signal, + awaitApproval: async () => 'allow' + }) + if (discovered.item.kind !== 'tool_result') { + throw new Error(`expected tool_result, received ${discovered.item.kind}`) + } + const discoveredOutput = discovered.item.output as { + profiles: Array<{ id: string; name: string; description: string; toolPolicy: string }> + } + expect(discovered.item).toMatchObject({ + kind: 'tool_result', + isError: false, + output: { + mode: 'profiles-only', + surface: 'design' + } + }) + expect(discovered.item.output).not.toHaveProperty('customAgent') + expect(discoveredOutput.profiles).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: 'reviewer', + name: 'Workspace Reviewer', + description: 'Workspace-specific API contract review', + toolPolicy: 'inherit' + }), + expect.objectContaining({ + id: 'workspace-only', + name: 'Workspace Only', + toolPolicy: 'readOnly' + }), + expect.objectContaining({ id: 'design-only' }) + ])) + expect(discoveredOutput.profiles).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'primary' }), + expect.objectContaining({ id: 'code-only' }) + ])) + expect(JSON.stringify(discovered.item.output)).not.toContain('Workspace-only role body.') + await expect(runtime.listWorkspaceProfiles(dir)).resolves.toEqual(expect.arrayContaining([ expect.objectContaining({ id: 'workspace-only', diff --git a/kun/tests/hybrid-store.test.ts b/kun/tests/hybrid-store.test.ts index dd65f582f..eed651386 100644 --- a/kun/tests/hybrid-store.test.ts +++ b/kun/tests/hybrid-store.test.ts @@ -412,6 +412,9 @@ describe('HybridThreadStore', () => { inflight: new InflightTracker(), steering: new SteeringQueue(), compactor: new ContextCompactor(), + attachmentStore: () => ({ + bindScopes: async () => [] + } as unknown as import('../src/attachments/attachment-store.js').AttachmentStore), ids: new SequentialIdGenerator(), nowIso: () => '2026-06-04T00:00:02.000Z' }) diff --git a/kun/tests/image-gen-tool-provider.test.ts b/kun/tests/image-gen-tool-provider.test.ts index dc1e645bf..04074abc5 100644 --- a/kun/tests/image-gen-tool-provider.test.ts +++ b/kun/tests/image-gen-tool-provider.test.ts @@ -346,8 +346,8 @@ describe('Image gen tool provider', () => { }), { status: 200, headers: { 'content-type': 'application/json' } }) })) const client = new GrokImagineImageClient('https://api.x.ai/v1', 'grok-access', { - 'x-grok-client-version': '0.2.106', - 'x-grok-client-identifier': 'kun' + 'x-grok-client-version': '0.2.112', + 'x-grok-client-identifier': 'grok-shell' }) const image = await client.generate({ @@ -362,8 +362,8 @@ describe('Image gen tool provider', () => { expect(image.data.byteLength).toBeGreaterThan(0) expect(requests[0].url).toBe('https://api.x.ai/v1/images/generations') expect(requests[0].headers.get('authorization')).toBe('Bearer grok-access') - expect(requests[0].headers.get('x-grok-client-version')).toBe('0.2.106') - expect(requests[0].headers.get('x-grok-client-identifier')).toBe('kun') + expect(requests[0].headers.get('x-grok-client-version')).toBe('0.2.112') + expect(requests[0].headers.get('x-grok-client-identifier')).toBe('grok-shell') expect(requests[0].body).toEqual({ model: 'grok-imagine-image-quality', prompt: 'cinematic mountain lake', diff --git a/kun/tests/loop-test-harness.ts b/kun/tests/loop-test-harness.ts index b1a551c21..4ed7e66ec 100644 --- a/kun/tests/loop-test-harness.ts +++ b/kun/tests/loop-test-harness.ts @@ -122,7 +122,10 @@ export function makeHarness( steering, compactor, ids, - nowIso + nowIso, + ...(options.attachmentStore + ? { attachmentStore: () => options.attachmentStore } + : {}) }) const threads = new ThreadService({ threadStore, sessionStore, events, ids, nowIso }) const loop = new AgentLoop({ diff --git a/kun/tests/loop.test.ts b/kun/tests/loop.test.ts index 42fa60021..20c68e99f 100644 --- a/kun/tests/loop.test.ts +++ b/kun/tests/loop.test.ts @@ -305,6 +305,37 @@ describe('AgentLoop', () => { ]) }) + it('emits the selected model window and runtime compaction thresholds', async () => { + const h = makeHarness(makeSilentModel(), { + tools: [], + compactor: new ContextCompactor({ + softThreshold: 750, + hardThreshold: 850 + }), + modelCapabilities: (model) => ({ + id: model, + inputModalities: ['text'], + outputModalities: ['text'], + supportsToolCalling: true, + contextWindowTokens: 1_000, + messageParts: ['text'] + }) + }) + await bootstrapThread(h) + + await h.loop.runTurn(h.threadId, h.turnId) + + const events = await h.sessionStore.loadEventsSince(h.threadId, 0) + const snapshot = events.find((event) => event.kind === 'context_snapshot') + expect(snapshot).toMatchObject({ + kind: 'context_snapshot', + model: 'fake', + contextWindowTokens: 1_000, + softThresholdTokens: 750, + hardThresholdTokens: 850 + }) + }) + it('records provider endpoint diagnostics for model send stages', async () => { const model = { provider: 'compat', @@ -508,6 +539,21 @@ describe('AgentLoop', () => { expect(events.some((event) => event.kind === 'tool_result_upload_wait' && event.toolResultCount === 1 )).toBe(true) + const contextSnapshots = events.filter((event) => event.kind === 'context_snapshot') + expect(contextSnapshots).toHaveLength(2) + expect(contextSnapshots.map((event) => event.stepIndex)).toEqual([0, 1]) + for (const snapshot of contextSnapshots) { + expect(snapshot.estimatedInputTokens).toBe( + snapshot.breakdown.tools + + snapshot.breakdown.system + + snapshot.breakdown.skills + + snapshot.breakdown.messages + + snapshot.breakdown.other + ) + expect(snapshot.toolCount).toBeGreaterThan(0) + } + expect(contextSnapshots[1]?.breakdown.messages) + .toBeGreaterThan(contextSnapshots[0]?.breakdown.messages ?? 0) const thread = await h.threadStore.get(h.threadId) const toolCall = thread?.turns .flatMap((turn) => turn.items) @@ -2557,6 +2603,104 @@ describe('AgentLoop', () => { } }) + it('reopens the plan gate when guidance is accepted after a successful plan write', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'kun-loop-plan-guidance-')) + const requests: ModelRequest[] = [] + let releaseSecondResponse: (() => void) | undefined + let markSecondResponseStarted: (() => void) | undefined + const secondResponseStarted = new Promise((resolve) => { + markSecondResponseStarted = resolve + }) + const secondResponseRelease = new Promise((resolve) => { + releaseSecondResponse = resolve + }) + try { + const model = { + provider: 'planner', + model: 'plan-guidance-model', + async *stream(request: ModelRequest): AsyncIterable { + requests.push(request) + if (requests.length === 1) { + yield { + kind: 'tool_call_complete', + callId: 'call_plan_initial', + toolName: CREATE_PLAN_TOOL_NAME, + arguments: { + markdown: '## Plan\nFollow the repository ignore rules.', + operation: 'draft', + source_request: 'Plan the restriction change' + } + } + yield { kind: 'completed', stopReason: 'tool_calls' } + return + } + if (requests.length === 2) { + markSecondResponseStarted?.() + await secondResponseRelease + yield { kind: 'assistant_text_delta', text: 'Initial plan saved.' } + yield { kind: 'completed', stopReason: 'stop' } + return + } + if (requests.length === 3) { + yield { + kind: 'tool_call_complete', + callId: 'call_plan_refined', + toolName: CREATE_PLAN_TOOL_NAME, + arguments: { + markdown: '## Plan\nFollow both repository ignore and hasconfig rules.', + operation: 'refine', + plan_relative_path: '.kunsdd/plan/plan-the-restriction-change.md', + source_request: 'Plan the restriction change' + } + } + yield { kind: 'completed', stopReason: 'tool_calls' } + return + } + yield { kind: 'assistant_text_delta', text: 'Updated the plan with the added constraint.' } + yield { kind: 'completed', stopReason: 'stop' } + } + } + const h = makeHarness(model, { tools: buildDefaultLocalTools() }) + await bootstrapThread(h, { + workspace, + request: { + prompt: 'Plan the restriction change', + mode: 'plan', + model: model.model + } + }) + + const run = h.loop.runTurn(h.threadId, h.turnId) + await secondResponseStarted + await h.turns.steerTurn({ + threadId: h.threadId, + turnId: h.turnId, + text: 'Also follow the hasconfig rules' + }) + releaseSecondResponse?.() + + await expect(run).resolves.toBe('completed') + expect(requests).toHaveLength(4) + expect(requests[2]).toMatchObject({ + model: model.model, + requiredToolName: CREATE_PLAN_TOOL_NAME + }) + expect(requests[2]?.modeInstruction).toContain('You are in Plan mode.') + expect(requests[2]?.history).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: 'user_message', + text: 'Also follow the hasconfig rules' + }) + ])) + await expect( + readFile(join(workspace, '.kunsdd/plan/plan-the-restriction-change.md'), 'utf8') + ).resolves.toBe('## Plan\nFollow both repository ignore and hasconfig rules.') + } finally { + releaseSecondResponse?.() + await rm(workspace, { recursive: true, force: true }) + } + }) + it('steers the turn and injects user messages', async () => { const h = makeHarness(makeSilentModel()) await bootstrapThread(h) diff --git a/kun/tests/mcp-tool-provider.test.ts b/kun/tests/mcp-tool-provider.test.ts index 9ac36e461..b3654bc9d 100644 --- a/kun/tests/mcp-tool-provider.test.ts +++ b/kun/tests/mcp-tool-provider.test.ts @@ -256,6 +256,7 @@ describe('MCP tool provider', () => { expect(tools.map((tool) => tool.name)).toEqual([ 'mcp_search', 'mcp_describe', + 'mcp_read_only_call', 'mcp_call', 'mcp_refresh_catalog', 'mcp_github_search_issues' @@ -341,11 +342,12 @@ describe('MCP tool provider', () => { mode: 'search', active: true, indexedToolCount: 2, - advertisedToolCount: 9 + advertisedToolCount: 10 }) expect((await host.listTools(context)).map((tool) => tool.name)).toEqual([ 'mcp_search', 'mcp_describe', + 'mcp_read_only_call', 'mcp_call', 'mcp_refresh_catalog' ]) @@ -412,6 +414,7 @@ describe('MCP tool provider', () => { expect((await host.listTools(buildContext('/tmp/other'))).map((tool) => tool.name)).toEqual([ 'mcp_search', 'mcp_describe', + 'mcp_read_only_call', 'mcp_call', 'mcp_refresh_catalog' ]) @@ -449,6 +452,7 @@ describe('MCP tool provider', () => { expect((await host.listTools(buildContext('/tmp/project'))).map((tool) => tool.name)).toEqual([ 'mcp_search', 'mcp_describe', + 'mcp_read_only_call', 'mcp_call', 'mcp_refresh_catalog', 'mcp_codegraph_search_issues' diff --git a/kun/tests/media-gen-tool-provider.test.ts b/kun/tests/media-gen-tool-provider.test.ts index a829812a2..08f732694 100644 --- a/kun/tests/media-gen-tool-provider.test.ts +++ b/kun/tests/media-gen-tool-provider.test.ts @@ -425,8 +425,8 @@ describe('Media gen tool provider', () => { })) const updates: ToolExecutionUpdate[] = [] const client = new GrokImagineVideoClient('https://api.x.ai/v1', 'grok-access', { - 'x-grok-client-version': '0.2.106', - 'x-grok-client-identifier': 'kun' + 'x-grok-client-version': '0.2.112', + 'x-grok-client-identifier': 'grok-shell' }) const media = await client.generate({ @@ -446,7 +446,7 @@ describe('Media gen tool provider', () => { expect(media.data.toString('utf8')).toBe('grok-video') expect(requests[0].url).toBe('https://api.x.ai/v1/videos/generations') expect(requests[0].headers.get('authorization')).toBe('Bearer grok-access') - expect(requests[0].headers.get('x-grok-client-identifier')).toBe('kun') + expect(requests[0].headers.get('x-grok-client-identifier')).toBe('grok-shell') expect(requests[0].body).toEqual({ model: 'grok-imagine-video-1.5-preview', prompt: 'Animate the clouds', diff --git a/kun/tests/model-client.test.ts b/kun/tests/model-client.test.ts index 04c13467e..caf061c4d 100644 --- a/kun/tests/model-client.test.ts +++ b/kun/tests/model-client.test.ts @@ -266,7 +266,11 @@ describe('CompatModelClient', () => { expect(sentBodies[0]).toMatchObject({ model: 'gpt-5.5', stream: false, - instructions: 'You are a helpful assistant.', + instructions: ' ', + input: [{ + role: 'system', + content: 'You are a helpful assistant.' + }], store: false, reasoning: { effort: 'xhigh', summary: 'auto' }, include: ['reasoning.encrypted_content'] @@ -1115,14 +1119,16 @@ describe('CompatModelClient', () => { // drain } expect(sentAccept[0]).toBe('application/json') + // Non-DeepSeek OpenAI-compat hosts must not receive DeepSeek-only `thinking` + // (see compat-request-builder nativeDeepSeekHost scoping / issue #26). expect(sentBodies[0]).toMatchObject({ model: 'deepseek-v4-flash', stream: false, max_tokens: 96, temperature: 0, - response_format: { type: 'json_object' }, - thinking: { type: 'disabled' } + response_format: { type: 'json_object' } }) + expect(sentBodies[0]).not.toHaveProperty('thinking') }) it('requests usage in streaming responses', async () => { diff --git a/kun/tests/thread-mutation-coordinator.test.ts b/kun/tests/thread-mutation-coordinator.test.ts index 37ed4972c..8ed5a61df 100644 --- a/kun/tests/thread-mutation-coordinator.test.ts +++ b/kun/tests/thread-mutation-coordinator.test.ts @@ -100,13 +100,14 @@ describe('shared thread mutation coordination', () => { expect(thread?.turns[0]).toMatchObject({ id: h.turnId, status: 'completed' }) }) - it('preserves a delayed generated title and a concurrently-started next turn', async () => { + it('preserves a delayed generated title while the first turn finishes concurrently', async () => { + // Title generation now runs in parallel with the first turn (before any + // completed turn). Race the title mutation against finishTurn. const h = makeHarness(makeFakeModel([ { kind: 'assistant_text_delta', text: 'Generated task title' }, { kind: 'completed', stopReason: 'stop' } ])) await bootstrapThread(h) - await h.turns.finishTurn({ threadId: h.threadId, turnId: h.turnId, status: 'completed' }) await h.threads.update(h.threadId, { title: 'New chat', titleAuto: true }) const block = blockThreadRead(h, 2) const loop = h.loop as unknown as { @@ -116,24 +117,22 @@ describe('shared thread mutation coordination', () => { const title = loop.maybeGenerateThreadTitle(h.threadId, h.turnId) await block.entered - let startSettled = false - const started = h.turns.startTurn({ + let finishSettled = false + const finish = h.turns.finishTurn({ threadId: h.threadId, - request: { prompt: 'second request' } + turnId: h.turnId, + status: 'completed' }).finally(() => { - startSettled = true + finishSettled = true }) await flushCompetingMutation() - const startWaitedForTitle = startSettled + const finishWaitedForTitle = finishSettled block.release() - const [, turn] = await Promise.all([title, started]) - expect(startWaitedForTitle).toBe(false) + await Promise.all([title, finish]) + expect(finishWaitedForTitle).toBe(false) const thread = await h.threadStore.get(h.threadId) - expect(thread).toMatchObject({ title: 'Generated task title', titleAuto: true, status: 'running' }) - expect(thread?.turns).toHaveLength(2) - expect(thread?.turns.at(-1)?.id).toBe(turn.turnId) - - await h.turns.interruptTurn({ threadId: h.threadId, turnId: turn.turnId }) + expect(thread).toMatchObject({ title: 'Generated task title', titleAuto: true, status: 'idle' }) + expect(thread?.turns[0]).toMatchObject({ id: h.turnId, status: 'completed' }) }) }) diff --git a/package-lock.json b/package-lock.json index 44d536159..0f8695a93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,7 @@ "better-sqlite3": "12.11.1", "diff": "^8.0.4", "electron-store": "^10.1.0", - "electron-updater": "^6.8.3", + "electron-updater": "^6.8.9", "extract-zip": "^2.0.1", "html-to-docx": "^1.8.0", "i18next": "^25.4.2", @@ -7433,9 +7433,9 @@ } }, "node_modules/builder-util-runtime": { - "version": "9.5.1", - "resolved": "https://registry.npmmirror.com/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", - "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", + "version": "9.7.0", + "resolved": "https://registry.npmmirror.com/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -8747,12 +8747,12 @@ "license": "ISC" }, "node_modules/electron-updater": { - "version": "6.8.3", - "resolved": "https://registry.npmmirror.com/electron-updater/-/electron-updater-6.8.3.tgz", - "integrity": "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==", + "version": "6.8.9", + "resolved": "https://registry.npmmirror.com/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", "license": "MIT", "dependencies": { - "builder-util-runtime": "9.5.1", + "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", diff --git a/package.json b/package.json index 284e47516..55b5cdb43 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "preview": "electron-vite preview", "typecheck": "npm run build:extensions && tsc --noEmit -p tsconfig.web.json && tsc --noEmit -p tsconfig.node.json", "prepare:whisper": "node ./scripts/prepare-whisper-runner.cjs", + "prepare:officecli": "node ./scripts/prepare-officecli.cjs", "mac:unquarantine": "bash ./scripts/mac-unquarantine.sh" }, "dependencies": { @@ -100,7 +101,7 @@ "better-sqlite3": "12.11.1", "diff": "^8.0.4", "electron-store": "^10.1.0", - "electron-updater": "^6.8.3", + "electron-updater": "^6.8.9", "extract-zip": "^2.0.1", "html-to-docx": "^1.8.0", "i18next": "^25.4.2", diff --git a/release/release-v0.2.31.md b/release/release-v0.2.31.md new file mode 100644 index 000000000..6256aa632 --- /dev/null +++ b/release/release-v0.2.31.md @@ -0,0 +1,35 @@ +# Kun v0.2.31 + +v0.2.31 聚焦长会话、跨模型协作和桌面端数据安全。它补齐了附件、会话输入、子代理上下文、Office 文档工具和多 Provider 的工作流,并收紧了 checkpoint 与凭据恢复的安全边界。 + +### 会话与 Agent 工作流 + +- Composer 支持输入历史和更完整的键盘快捷操作;模型标题可并行生成,会话整理更顺畅。 +- Cursor 中断会保留已生成的 partial 输出;Codex 压缩后的输入会继续传递给后续回合。 +- 子代理会保留委派状态、上下文容量和能力快照,Agent 视角可更完整地说明一次协作回合。 +- `request_user_input` 兼容可选问题字段和 `prompt` / `message` 别名,空请求不会再生成无效的交互块。 +- 对话时间线将进行中的思考与已完成工具批次分开呈现,过程信息更清晰。 + +### 文件、文档与模型接入 + +- 附件改用受约束的 scope 绑定,降低跨会话附件错配的风险;工作区预览和附件加载也更稳定。 +- 内置 OfficeCLI 工具支持常用 Office 文档的读取、创建和编辑。发布构建会校验各平台对应的资源与目标清单。 +- Provider 设置补充 API Key 必填判断、Gemini OpenAI Host 识别及 reasoning effort 处理。 +- Windows 更新重试不会再显示 NSIS 静默更新对话框;更新组件已升级以包含已知凭据泄漏修复。 +- Windows DPAPI 保存的凭据无法解密时,初始设置会提供“重试解密”及“备份并重置”恢复路径,不会静默覆盖既有凭据。 + +### Git checkpoint 与数据保护 + +- 发送消息时默认创建 Git checkpoint;如不需要,可在设置中明确关闭。 +- 自动清理、启动/升级清理和每线程保留上限只会删除未被会话历史引用的 checkpoint。任何仍作为消息回滚目标的 checkpoint 都会保留,避免回滚入口指向已删除的数据。 +- 每线程上限因此是对未引用 checkpoint 的软上限。较长会话保留较多可回滚点时可占用更多磁盘空间;可通过归档、删除不再需要的会话或关闭后续 checkpoint 创建来控制增长。 + +### 升级说明 + +- 从 `v0.2.30` 升级无需迁移工作区、会话或 Provider 设置。 +- 旧配置中未设置 checkpoint 创建开关时会采用新的默认值(开启);已经明确关闭该开关的配置保持关闭。 +- 如 Windows 提示无法读取旧 DPAPI 凭据,请先重试。只有确认凭据不可恢复时才使用“备份并重置”;该操作会备份不可读取的凭据数据并要求重新登录或填写 API Key,不会删除会话、工作区或普通设置。 + +### 完整变更 + +https://github.com/KunAgent/Kun/compare/v0.2.30...v0.2.31 diff --git a/resources/officecli/current/.gitignore b/resources/officecli/current/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/resources/officecli/current/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/resources/officecli/legal/LICENSE b/resources/officecli/legal/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/resources/officecli/legal/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/resources/officecli/legal/NOTICE b/resources/officecli/legal/NOTICE new file mode 100644 index 000000000..5df81e65d --- /dev/null +++ b/resources/officecli/legal/NOTICE @@ -0,0 +1,13 @@ +OfficeCLI +Copyright 2026 OfficeCLI (https://OfficeCLI.AI) + +Created and maintained by goworm. + +This product is licensed under the Apache License, Version 2.0. +You may obtain a copy of the License in the LICENSE file or at +http://www.apache.org/licenses/LICENSE-2.0 + +This NOTICE file is part of the required attribution under +Section 4 of the Apache License, Version 2.0. Redistributions +of this work, with or without modification, must retain this +notice. diff --git a/resources/officecli/legal/THIRD-PARTY-NOTICES.txt b/resources/officecli/legal/THIRD-PARTY-NOTICES.txt new file mode 100644 index 000000000..de9ecaf2c --- /dev/null +++ b/resources/officecli/legal/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,49 @@ +OfficeCLI — Third-Party Notices + +This product bundles third-party components. Their copyright notices and +license terms are reproduced below as required by their respective licenses. + +================================================================================ +DocumentFormat.OpenXml (3.4.1) +https://github.com/dotnet/Open-XML-SDK + +Copyright (c) Microsoft Corporation + +Licensed under the MIT License. See "MIT License" below. + +================================================================================ +System.CommandLine (3.0.0-preview.2.26159.112) +https://github.com/dotnet/command-line-api + +Copyright (c) .NET Foundation and Contributors + +Licensed under the MIT License. See "MIT License" below. + +================================================================================ +.NET Runtime (bundled by self-contained publish) +https://github.com/dotnet/runtime + +Copyright (c) .NET Foundation and Contributors + +Licensed under the MIT License. See "MIT License" below. + +================================================================================ +MIT License +-------------------------------------------------------------------------------- +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/resources/officecli/manifest.json b/resources/officecli/manifest.json new file mode 100644 index 000000000..38552f000 --- /dev/null +++ b/resources/officecli/manifest.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 1, + "version": "1.0.141", + "releaseTag": "v1.0.141", + "schemaCrc": "2da9da05", + "license": "Apache-2.0", + "assets": { + "darwin-arm64": { + "name": "officecli-mac-arm64", + "size": 33686928, + "sha256": "a9639df060513d73b125849e4c630383f7a80f70e61911ef486dd24ec2208e37", + "url": "https://github.com/iOfficeAI/OfficeCLI/releases/download/v1.0.141/officecli-mac-arm64" + }, + "darwin-x64": { + "name": "officecli-mac-x64", + "size": 34626016, + "sha256": "4a46518c19b42dc28bc7dfad74101e666ee81cf0c11a9843979e2d39ce0c68e6", + "url": "https://github.com/iOfficeAI/OfficeCLI/releases/download/v1.0.141/officecli-mac-x64" + }, + "linux-x64": { + "name": "officecli-linux-x64", + "size": 35239077, + "sha256": "4a542155ce3e1b0c211ba117d5d3bc6c25357d74fd5ba55786f1c29c12ac866e", + "url": "https://github.com/iOfficeAI/OfficeCLI/releases/download/v1.0.141/officecli-linux-x64" + }, + "win32-x64": { + "name": "officecli-win-x64.exe", + "size": 33300392, + "sha256": "65d119912147b47d102224715df2288813a2fea56520bfc4313b2fa0bf4672c7", + "url": "https://github.com/iOfficeAI/OfficeCLI/releases/download/v1.0.141/officecli-win-x64.exe" + } + } +} diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 06a4e2fec..89950121a 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -27,6 +27,10 @@ const KUN_RUNTIME_REQUIRED_PATHS = [ 'kun/node_modules/semver/package.json', 'kun/node_modules/yauzl/package.json', 'kun/node_modules/yazl/package.json', + 'kun/node_modules/typescript/package.json', + 'kun/node_modules/typescript/lib/typescript.js', + 'kun/node_modules/typescript-language-server/package.json', + 'kun/node_modules/typescript-language-server/lib/cli.mjs', 'kun/node_modules/@cursor/sdk/package.json', 'kun/node_modules/@modelcontextprotocol/sdk/package.json', 'kun/node_modules/@kun/extension-api/package.json', @@ -54,6 +58,7 @@ const LINUX_SANDBOX_LAUNCHER_FLAG = '--disable-setuid-sandbox' const LINUX_REAL_EXECUTABLE_SUFFIX = '.electron-bin' const BUNDLED_EXTENSIONS_DIR = 'bundled-extensions' const BUNDLED_EXTENSION_CATALOG_FILE = 'catalog.json' +const OFFICECLI_DIR = 'officecli' const REQUIRED_BUNDLED_EXTENSION_IDS = [ 'kun-examples.kun-video-editor', 'kun-examples.presentation-studio', @@ -213,6 +218,86 @@ function validateBundledExtensionResources(context) { } } +function validateBundledOfficeCli(context) { + const platform = normalizePlatform(context.electronPlatformName) + const arch = normalizeArch(context.arch) + const root = join(packedResourcesDir(context), OFFICECLI_DIR) + const manifestPath = join(root, 'manifest.json') + const selectedPath = join(root, 'selected.json') + assertRegularNonSymlink(manifestPath, 'OfficeCLI manifest') + assertRegularNonSymlink(selectedPath, 'OfficeCLI selected target manifest') + + let manifest + let selected + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + selected = JSON.parse(readFileSync(selectedPath, 'utf8')) + } catch (error) { + throw new Error(`[after-pack] Invalid OfficeCLI manifest: ${error.message}`) + } + const targetKey = `${platform}-${arch}` + const expected = manifest?.assets?.[targetKey] + if ( + manifest?.schemaVersion !== 1 || + manifest?.version !== '1.0.141' || + !expected || + selected?.schemaVersion !== 1 || + selected?.version !== manifest.version || + selected?.schemaCrc !== manifest.schemaCrc || + selected?.platform !== platform || + selected?.arch !== arch || + selected?.sha256 !== expected.sha256 || + selected?.size !== expected.size + ) { + throw new Error(`[after-pack] OfficeCLI target manifest does not match ${targetKey}`) + } + + const executableName = platform === 'win32' ? 'officecli.exe' : 'officecli' + const executablePath = join(root, executableName) + assertRegularNonSymlink(executablePath, `OfficeCLI ${targetKey} executable`) + const details = lstatSync(executablePath) + if (details.size !== expected.size) { + throw new Error(`[after-pack] OfficeCLI size mismatch for ${targetKey}`) + } + const digest = createHash('sha256').update(readFileSync(executablePath)).digest('hex') + if (digest !== expected.sha256) { + throw new Error(`[after-pack] OfficeCLI digest mismatch for ${targetKey}`) + } + if (platform !== 'win32') { + chmodSync(executablePath, 0o755) + if ((lstatSync(executablePath).mode & 0o111) === 0) { + throw new Error(`[after-pack] OfficeCLI is not executable for ${targetKey}`) + } + } + + for (const legalFile of ['LICENSE', 'NOTICE', 'THIRD-PARTY-NOTICES.txt']) { + assertRegularNonSymlink(join(root, 'legal', legalFile), `OfficeCLI ${legalFile}`) + } + const binaryEntries = readdirSync(root).filter((entry) => + entry === 'officecli' || entry === 'officecli.exe' + ) + if (binaryEntries.length !== 1 || binaryEntries[0] !== executableName) { + throw new Error(`[after-pack] Expected exactly one ${targetKey} OfficeCLI executable`) + } +} + +async function maybeSignBundledOfficeCli(context) { + const platform = normalizePlatform(context.electronPlatformName) + if (platform !== 'win32') return false + const signIf = context.packager?.signIf + if (typeof signIf !== 'function') { + throw new Error('[after-pack] Windows packager cannot sign the bundled OfficeCLI executable') + } + const executablePath = join(packedResourcesDir(context), OFFICECLI_DIR, 'officecli.exe') + const signed = await signIf.call(context.packager, executablePath) + console.log( + signed + ? '[after-pack] Signed bundled OfficeCLI executable.' + : '[after-pack] OfficeCLI signing was skipped because Windows signing is not configured.' + ) + return signed +} + function assertRegularNonSymlink(path, label) { assertExists(path, label) const details = lstatSync(path) @@ -350,7 +435,7 @@ function installLinuxElectronLauncher(context) { function normalizeArch(arch) { if (arch === 'x64' || arch === 1) return 'x64' if (arch === 'arm64' || arch === 3) return 'arm64' - throw new Error(`[after-pack] Unsupported Whisper runner arch: ${arch}`) + throw new Error(`[after-pack] Unsupported packaged resource arch: ${arch}`) } function prunePackedWhisperResources(context) { @@ -370,6 +455,8 @@ async function afterPack(context) { materializePackedWorkspaceDependencies(context) validateBundledKunRuntime(context) validateBundledExtensionResources(context) + validateBundledOfficeCli(context) + await maybeSignBundledOfficeCli(context) prunePackedWhisperResources(context) ensureNodePtyHelpersExecutable(context) installLinuxElectronLauncher(context) @@ -389,6 +476,8 @@ exports._internals = { materializePackedWorkspaceDependencies, validateBundledKunRuntime, validateBundledExtensionResources, + validateBundledOfficeCli, + maybeSignBundledOfficeCli, normalizeArch, prunePackedWhisperResources, ensureNodePtyHelpersExecutable, diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index b59b421fc..2ce434835 100644 --- a/scripts/before-pack.cjs +++ b/scripts/before-pack.cjs @@ -21,12 +21,27 @@ async function beforePack(context) { const arch = normalizeArch(context.arch) if (process.env.KUN_SKIP_WHISPER_RUNNER === '1') { console.warn(`[before-pack] Skipping bundled Whisper runner for ${platform}-${arch}.`) - return + } else { + execFileSync( + process.execPath, + [ + join(__dirname, 'prepare-whisper-runner.cjs'), + '--platform', + platform, + '--arch', + arch + ], + { + cwd: join(__dirname, '..'), + stdio: 'inherit' + } + ) } + execFileSync( process.execPath, [ - join(__dirname, 'prepare-whisper-runner.cjs'), + join(__dirname, 'prepare-officecli.cjs'), '--platform', platform, '--arch', diff --git a/scripts/check-extension-release-gate.mjs b/scripts/check-extension-release-gate.mjs index 9a826242a..bd94f61af 100644 --- a/scripts/check-extension-release-gate.mjs +++ b/scripts/check-extension-release-gate.mjs @@ -654,6 +654,33 @@ check( 'Root postinstall must complete the Extension API/Kun bootstrap before native rebuilds' ) const kunLock = await json('kun/package-lock.json') +const kunPackage = await json('kun/package.json') +for (const [dependency, version] of [ + ['typescript', '5.9.3'], + ['typescript-language-server', '5.3.0'] +]) { + check( + kunPackage.dependencies?.[dependency] === version, + `Kun must pin bundled ${dependency}@${version} as a production dependency` + ) + check( + kunLock.packages?.['']?.dependencies?.[dependency] === version && + kunLock.packages?.[`node_modules/${dependency}`]?.dev !== true, + `Kun lockfile does not retain bundled production dependency ${dependency}@${version}` + ) +} +const ensureKunInstallSource = await text('scripts/ensure-kun-install.cjs') +for (const path of [ + 'kun/node_modules/typescript/package.json', + 'kun/node_modules/typescript/lib/typescript.js', + 'kun/node_modules/typescript-language-server/package.json', + 'kun/node_modules/typescript-language-server/lib/cli.mjs' +]) { + check( + ensureKunInstallSource.includes(`'${path}'`), + `Kun bootstrap does not require bundled LSP resource: ${path}` + ) +} const semver = requireKun('semver') const wasmRuntimeLock = kunLock.packages?.['node_modules/@napi-rs/wasm-runtime'] for (const dependency of ['@emnapi/core', '@emnapi/runtime']) { @@ -737,6 +764,10 @@ for (const pattern of [ for (const path of [ 'kun/dist/cli/extension-cli.js', 'kun/dist/extensions/host-runner.js', + 'kun/node_modules/typescript/package.json', + 'kun/node_modules/typescript/lib/typescript.js', + 'kun/node_modules/typescript-language-server/package.json', + 'kun/node_modules/typescript-language-server/lib/cli.mjs', 'kun/node_modules/@kun/extension-api/dist/index.js', 'kun/node_modules/create-kun-extension/src/cli.mjs', 'node_modules/better-sqlite3/package.json', diff --git a/scripts/ensure-kun-install.cjs b/scripts/ensure-kun-install.cjs index 4b67f4c6a..095b84c26 100644 --- a/scripts/ensure-kun-install.cjs +++ b/scripts/ensure-kun-install.cjs @@ -8,6 +8,10 @@ const REQUIRED_PATHS = [ 'kun/node_modules/yauzl/package.json', 'kun/node_modules/yazl/package.json', 'kun/node_modules/zod/package.json', + 'kun/node_modules/typescript/package.json', + 'kun/node_modules/typescript/lib/typescript.js', + 'kun/node_modules/typescript-language-server/package.json', + 'kun/node_modules/typescript-language-server/lib/cli.mjs', 'kun/node_modules/@cursor/sdk/package.json', 'kun/node_modules/@modelcontextprotocol/sdk/package.json', 'kun/node_modules/@kun/extension-api/package.json', diff --git a/scripts/prepare-officecli.cjs b/scripts/prepare-officecli.cjs new file mode 100644 index 000000000..53208927d --- /dev/null +++ b/scripts/prepare-officecli.cjs @@ -0,0 +1,121 @@ +const { createHash, randomUUID } = require('node:crypto') +const { chmod, mkdir, readFile, rename, rm, stat, writeFile } = require('node:fs/promises') +const { join } = require('node:path') + +const PROJECT_ROOT = join(__dirname, '..') +const OFFICECLI_ROOT = join(PROJECT_ROOT, 'resources', 'officecli') +const CURRENT_ROOT = join(OFFICECLI_ROOT, 'current') +const MANIFEST_PATH = join(OFFICECLI_ROOT, 'manifest.json') +const MAX_DOWNLOAD_BYTES = 48 * 1024 * 1024 + +function parseArgs(argv) { + const output = { + platform: process.platform, + arch: process.arch + } + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] === '--platform') output.platform = argv[index + 1] + if (argv[index] === '--arch') output.arch = argv[index + 1] + } + if (output.platform === 'mac') output.platform = 'darwin' + if (output.platform === 'win') output.platform = 'win32' + return output +} + +function executableName(platform) { + return platform === 'win32' ? 'officecli.exe' : 'officecli' +} + +async function sha256(path) { + return createHash('sha256').update(await readFile(path)).digest('hex') +} + +async function fileMatches(path, asset) { + try { + const details = await stat(path) + return details.isFile() && + details.size === asset.size && + await sha256(path) === asset.sha256 + } catch { + return false + } +} + +async function downloadAsset(url) { + const response = await fetch(url, { + redirect: 'follow', + headers: { 'user-agent': 'Kun-OfficeCLI-Packager/1' } + }) + if (!response.ok) throw new Error(`OfficeCLI download failed with HTTP ${response.status}`) + const declaredSize = Number(response.headers.get('content-length') || 0) + if (declaredSize > MAX_DOWNLOAD_BYTES) { + throw new Error(`OfficeCLI download declares ${declaredSize} bytes, above the limit`) + } + const bytes = Buffer.from(await response.arrayBuffer()) + if (bytes.length <= 0 || bytes.length > MAX_DOWNLOAD_BYTES) { + throw new Error(`OfficeCLI download returned an invalid ${bytes.length} byte payload`) + } + return bytes +} + +async function prepareOfficeCli({ platform, arch }) { + const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8')) + const key = `${platform}-${arch}` + const asset = manifest.assets?.[key] + if (!asset) throw new Error(`OfficeCLI ${manifest.version} does not support build target ${key}`) + + await mkdir(CURRENT_ROOT, { recursive: true, mode: 0o755 }) + const outputName = executableName(platform) + const outputPath = join(CURRENT_ROOT, outputName) + const oppositePath = join(CURRENT_ROOT, platform === 'win32' ? 'officecli' : 'officecli.exe') + await rm(oppositePath, { force: true }) + + if (!await fileMatches(outputPath, asset)) { + const bytes = await downloadAsset(asset.url) + if (bytes.length !== asset.size) { + throw new Error(`OfficeCLI size mismatch for ${key}: expected ${asset.size}, got ${bytes.length}`) + } + const digest = createHash('sha256').update(bytes).digest('hex') + if (digest !== asset.sha256) { + throw new Error(`OfficeCLI SHA-256 mismatch for ${key}: expected ${asset.sha256}, got ${digest}`) + } + const temporaryPath = join(CURRENT_ROOT, `.${outputName}.${randomUUID()}.tmp`) + try { + await writeFile(temporaryPath, bytes, { mode: 0o755 }) + await chmod(temporaryPath, 0o755) + await rename(temporaryPath, outputPath) + } finally { + await rm(temporaryPath, { force: true }) + } + } + if (platform !== 'win32') await chmod(outputPath, 0o755) + + const selected = { + schemaVersion: 1, + version: manifest.version, + releaseTag: manifest.releaseTag, + schemaCrc: manifest.schemaCrc, + platform, + arch, + asset: asset.name, + size: asset.size, + sha256: asset.sha256 + } + await writeFile(join(CURRENT_ROOT, 'selected.json'), `${JSON.stringify(selected, null, 2)}\n`, 'utf8') + console.log(`[officecli] prepared ${manifest.version} for ${key} (${asset.size} bytes)`) + return selected +} + +if (require.main === module) { + prepareOfficeCli(parseArgs(process.argv.slice(2))).catch((error) => { + console.error(`[officecli] ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 + }) +} + +exports._internals = { + parseArgs, + executableName, + prepareOfficeCli, + fileMatches +} diff --git a/src/main/agent-sdk-installer.ts b/src/main/agent-sdk-installer.ts index 608c538e0..926946aee 100644 --- a/src/main/agent-sdk-installer.ts +++ b/src/main/agent-sdk-installer.ts @@ -17,7 +17,7 @@ import { join } from 'node:path' import { fetchWithOptionalProxy } from './proxy-fetch' // Keep in sync with kun/package.json's @anthropic-ai/claude-agent-sdk version. -export const AGENT_SDK_VERSION = '0.3.193' +export const AGENT_SDK_VERSION = '0.3.220' const REGISTRY = 'https://registry.npmjs.org' export function claudeBinaryName(): string { diff --git a/src/main/antigravity-cli.ts b/src/main/antigravity-cli.ts index 65adc269c..89ca33da5 100644 --- a/src/main/antigravity-cli.ts +++ b/src/main/antigravity-cli.ts @@ -1,9 +1,9 @@ /** * Official Antigravity CLI provisioning and model discovery. * - * Google moved consumer Gemini subscriptions from Gemini CLI / Code Assist to - * Antigravity CLI. Kun therefore invokes the official `agy` binary instead of - * calling the retired Code Assist v1internal transport. + * This is the whole-turn Antigravity subscription transport. It intentionally + * remains separate from Kun's Gemini CLI API provider, which reuses the + * official Gemini CLI OAuth login and Code Assist request contract. */ import { spawn } from 'node:child_process' import { createHash } from 'node:crypto' diff --git a/src/main/claude-subscription-auth.test.ts b/src/main/claude-subscription-auth.test.ts index 17ecf0a61..ed2e92e5b 100644 --- a/src/main/claude-subscription-auth.test.ts +++ b/src/main/claude-subscription-auth.test.ts @@ -2,73 +2,213 @@ import { EventEmitter } from 'node:events' import { mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, test } from 'vitest' -import { resolveBundledClaudeBinary, runClaudeSetupToken } from './claude-subscription-auth' +import { describe, expect, test, vi } from 'vitest' +import { + claudeSubscriptionStatus, + probeClaudeSubscription, + resolveBundledClaudeBinary, + runClaudeSubscriptionLogin, + validateClaudeSubscriptionToken +} from './claude-subscription-auth' function fakeChild(): EventEmitter & { stdout: EventEmitter stderr: EventEmitter - kill: () => void + kill: ReturnType } { const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter stderr: EventEmitter - kill: () => void + kill: ReturnType } child.stdout = new EventEmitter() child.stderr = new EventEmitter() - child.kill = () => {} + child.kill = vi.fn() return child } -describe('runClaudeSetupToken', () => { - test('captures the OAuth token printed across stdout chunks', async () => { +describe('validateClaudeSubscriptionToken', () => { + test('accepts only a complete setup-token value', () => { + expect(validateClaudeSubscriptionToken(' sk-ant-oat01-AbC123_xyz-DEF ')).toEqual({ + ok: true, + token: 'sk-ant-oat01-AbC123_xyz-DEF' + }) + for (const invalid of [ + '', + 'Bearer sk-ant-oat01-token', + 'CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-token', + '"sk-ant-oat01-token"', + 'sk-ant-api03-console', + 'sk-ant-oat01-token suffix' + ]) { + expect(validateClaudeSubscriptionToken(invalid)).toEqual({ + ok: false, + message: 'invalid-token-format' + }) + } + }) +}) + +describe('claudeSubscriptionStatus', () => { + test('trusts structured CLI status even without a credentials file', async () => { const child = fakeChild() - const promise = runClaudeSetupToken({ spawnFn: (() => child) as never }) - child.stdout.emit('data', Buffer.from('Visit https://claude.ai/... then\n')) - child.stdout.emit('data', Buffer.from('Your token: sk-ant-oat01-AbC123_xyz-DEF\n')) - expect(await promise).toEqual({ ok: true, token: 'sk-ant-oat01-AbC123_xyz-DEF' }) + const promise = claudeSubscriptionStatus({ + credentialsPath: join(tmpdir(), 'kun-claude-status-missing'), + spawnFn: (() => child) as never + }) + child.stdout.emit('data', Buffer.from(JSON.stringify({ + loggedIn: true, + email: 'must-not-leak@example.test', + subscriptionType: 'pro' + }))) + child.emit('exit', 0) + expect(await promise).toEqual({ loggedIn: true, source: 'cli' }) + }) + + test('uses the credential file only as a compatibility fallback', async () => { + const root = join(tmpdir(), `kun-claude-status-${process.pid}-${Date.now()}`) + const credentialsPath = join(root, '.credentials.json') + mkdirSync(root, { recursive: true }) + writeFileSync(credentialsPath, '{}') + try { + const child = fakeChild() + const promise = claudeSubscriptionStatus({ + credentialsPath, + spawnFn: (() => child) as never + }) + child.stderr.emit('data', Buffer.from('unknown command auth status')) + child.emit('exit', 1) + expect(await promise).toEqual({ + loggedIn: true, + source: 'credentials-file', + message: 'cli-status-unavailable' + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } }) - test('reports a friendly message when the CLI is missing', async () => { + test('bounds a hung status command and returns a diagnostic code', async () => { const child = fakeChild() - const promise = runClaudeSetupToken({ spawnFn: (() => child) as never }) - const err = Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' }) - child.emit('error', err) - expect(await promise).toEqual({ ok: false, message: 'claude-cli-not-found' }) + await expect(claudeSubscriptionStatus({ + credentialsPath: join(tmpdir(), 'kun-claude-status-timeout-missing'), + spawnFn: (() => child) as never, + timeoutMs: 5 + })).resolves.toEqual({ + loggedIn: false, + source: 'none', + message: 'status-timeout' + }) + expect(child.kill).toHaveBeenCalled() }) +}) - test('fails when the process exits without a token', async () => { +describe('runClaudeSubscriptionLogin', () => { + test('completes from status polling and kills a still-open login helper', async () => { const child = fakeChild() - const promise = runClaudeSetupToken({ spawnFn: (() => child) as never }) - child.stderr.emit('data', Buffer.from('authorization cancelled')) + let checks = 0 + const promise = runClaudeSubscriptionLogin({ + binaryPath: '/bundled/claude', + spawnFn: (() => child) as never, + pollIntervalMs: 1, + timeoutMs: 100, + status: async () => ({ + loggedIn: ++checks >= 3, + source: checks >= 3 ? 'cli' : 'none' + }) + }) + await expect(promise).resolves.toEqual({ ok: true, mode: 'ambient' }) + expect(child.kill).toHaveBeenCalled() + }) + + test('does not spawn when ambient login already exists', async () => { + const spawnFn = vi.fn() + await expect(runClaudeSubscriptionLogin({ + spawnFn: spawnFn as never, + status: async () => ({ loggedIn: true, source: 'cli' }) + })).resolves.toEqual({ ok: true, mode: 'ambient' }) + expect(spawnFn).not.toHaveBeenCalled() + }) + + test('redacts token-like output when login exits unauthenticated', async () => { + const child = fakeChild() + const promise = runClaudeSubscriptionLogin({ + spawnFn: (() => child) as never, + status: async () => ({ loggedIn: false, source: 'none' }) + }) + await Promise.resolve() + child.stderr.emit('data', Buffer.from('rejected sk-ant-oat01-secret-value')) child.emit('exit', 1) const result = await promise - expect(result.ok).toBe(false) - if (!result.ok) expect(result.message).toContain('authorization cancelled') + expect(result).toEqual({ ok: false, message: 'rejected ' }) }) - test('only settles once (exit after a successful capture is ignored)', async () => { + test('times out and stops the helper', async () => { const child = fakeChild() - const promise = runClaudeSetupToken({ spawnFn: (() => child) as never }) - child.stdout.emit('data', Buffer.from('sk-ant-oat01-TOKEN')) - child.emit('exit', 0) - expect(await promise).toEqual({ ok: true, token: 'sk-ant-oat01-TOKEN' }) + await expect(runClaudeSubscriptionLogin({ + spawnFn: (() => child) as never, + status: async () => ({ loggedIn: false, source: 'none' }), + pollIntervalMs: 1, + timeoutMs: 5 + })).resolves.toEqual({ ok: false, message: 'timeout' }) + expect(child.kill).toHaveBeenCalled() + }) +}) + +describe('probeClaudeSubscription', () => { + test('rejects malformed tokens without spawning Claude', async () => { + const spawnFn = vi.fn() + await expect(probeClaudeSubscription({ + token: 'Bearer sk-ant-oat01-secret', + spawnFn: spawnFn as never + })).resolves.toEqual({ ok: false, message: 'invalid-token-format' }) + expect(spawnFn).not.toHaveBeenCalled() }) - test('spawns the provided bundled binaryPath instead of a PATH lookup', async () => { + test('makes a bounded real no-tools request with the OAuth token', async () => { const child = fakeChild() - let seenCommand: string | undefined - const promise = runClaudeSetupToken({ + const seen: { args?: string[]; envToken?: string } = {} + const promise = probeClaudeSubscription({ + token: 'sk-ant-oat01-valid-token', binaryPath: '/bundled/claude', - spawnFn: ((cmd: string) => { - seenCommand = cmd + spawnFn: ((_command: string, args: string[], options: { env?: NodeJS.ProcessEnv }) => { + seen.args = args + seen.envToken = options.env?.CLAUDE_CODE_OAUTH_TOKEN return child - }) as never + }) as never, + now: (() => { + let value = 100 + return () => (value += 25) + })() + }) + child.stdout.emit('data', Buffer.from('{"result":"KUN_AUTH_OK"}')) + child.emit('exit', 0) + expect(await promise).toEqual({ ok: true, latencyMs: 25 }) + expect(seen.args).toEqual(expect.arrayContaining([ + '-p', + '--no-session-persistence', + '--disable-slash-commands', + '--tools', + '' + ])) + expect(seen.envToken).toBe('sk-ant-oat01-valid-token') + }) + + test('returns a redacted upstream authentication failure', async () => { + const child = fakeChild() + const token = 'sk-ant-oat01-rejected-token' + const promise = probeClaudeSubscription({ + token, + spawnFn: (() => child) as never + }) + child.stderr.emit('data', Buffer.from(`Failed to authenticate: Invalid Bearer ${token}`)) + child.emit('exit', 1) + const result = await promise + expect(result).toEqual({ + ok: false, + message: 'Failed to authenticate: Invalid Bearer ' }) - child.stdout.emit('data', Buffer.from('sk-ant-oat01-Z')) - await promise - expect(seenCommand).toBe('/bundled/claude') + expect(JSON.stringify(result)).not.toContain(token) }) }) diff --git a/src/main/claude-subscription-auth.ts b/src/main/claude-subscription-auth.ts index 6ecd1004f..a0b068ebf 100644 --- a/src/main/claude-subscription-auth.ts +++ b/src/main/claude-subscription-auth.ts @@ -3,10 +3,9 @@ * * The compliant path does NOT do an in-app browser OAuth (Anthropic forbids * third-party apps offering claude.ai login). Instead the official Claude Code - * CLI performs the OAuth: we either detect an existing CLI login or shell out to - * `claude setup-token` (which opens the user's browser) and capture the printed - * `CLAUDE_CODE_OAUTH_TOKEN`. The token (or an empty value + existing CLI login) - * is then handed to the embedded Agent SDK runtime. + * CLI performs the OAuth. Ambient login is detected with `claude auth status` + * and started with `claude auth login`; a manually generated setup-token remains + * an optional `CLAUDE_CODE_OAUTH_TOKEN` for the embedded Agent SDK runtime. */ import { spawn, type ChildProcess } from 'node:child_process' import { existsSync } from 'node:fs' @@ -14,14 +13,158 @@ import { homedir } from 'node:os' import { join } from 'node:path' import type { ClaudeSubscriptionLoginResult, + ClaudeSubscriptionProbeResult, ClaudeSubscriptionStatus } from '../shared/kun-gui-api' +import { validateClaudeSubscriptionToken } from '../shared/claude-subscription-auth' -// `claude setup-token` prints a long-lived OAuth token; capture the first match. -const OAUTH_TOKEN_PATTERN = /sk-ant-oat[\w-]+/ +const OAUTH_TOKEN_REDACTION_PATTERN = /sk-ant-oat[\w-]+/g +const MAX_CAPTURE_BYTES = 64 * 1024 +const DEFAULT_STATUS_TIMEOUT_MS = 5_000 +const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60 * 1000 +const DEFAULT_PROBE_TIMEOUT_MS = 30_000 +const LOGIN_POLL_INTERVAL_MS = 500 +const ANSI_ESCAPE_SEQUENCE_PATTERN = new RegExp( + `${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, + 'g' +) -export function claudeSubscriptionStatus(): ClaudeSubscriptionStatus { - return { loggedIn: existsSync(join(homedir(), '.claude', '.credentials.json')) } +type SpawnFn = typeof spawn + +type CapturedCommandResult = { + code: number | null + stdout: string + stderr: string + timedOut: boolean +} + +export { validateClaudeSubscriptionToken } from '../shared/claude-subscription-auth' + +function claudeAuthEnv(token?: string): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env } + for (const key of [ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_BASE_URL', + 'CLAUDE_CODE_OAUTH_TOKEN', + 'CLAUDE_CONFIG_DIR', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_ANTHROPIC_AWS' + ]) { + delete env[key] + } + if (token) env.CLAUDE_CODE_OAUTH_TOKEN = token + return env +} + +function appendBounded(current: string, chunk: Buffer): string { + const next = current + chunk.toString() + return next.length > MAX_CAPTURE_BYTES ? next.slice(-MAX_CAPTURE_BYTES) : next +} + +function redactClaudeAuthText(value: string, token?: string): string { + let redacted = value.replace(ANSI_ESCAPE_SEQUENCE_PATTERN, '') + if (token) redacted = redacted.split(token).join('') + return redacted + .replace(OAUTH_TOKEN_REDACTION_PATTERN, '') + .replace(/\s+/g, ' ') + .trim() + .slice(-500) +} + +function runCapturedCommand(options: { + binaryPath?: string + args: string[] + env?: NodeJS.ProcessEnv + spawnFn?: SpawnFn + timeoutMs: number +}): Promise { + const spawnFn = options.spawnFn ?? spawn + return new Promise((resolve) => { + let child: ChildProcess | undefined + let stdout = '' + let stderr = '' + let settled = false + let timedOut = false + const finish = (code: number | null): void => { + if (settled) return + settled = true + clearTimeout(timer) + resolve({ code, stdout, stderr, timedOut }) + } + const timer = setTimeout(() => { + timedOut = true + try { + child?.kill() + } catch { + // ignore cleanup failures + } + finish(null) + }, options.timeoutMs) + try { + child = spawnFn(options.binaryPath ?? 'claude', options.args, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: !options.binaryPath && process.platform === 'win32', + env: options.env ?? claudeAuthEnv() + }) + } catch (error) { + stderr = error instanceof Error ? error.message : String(error) + finish(null) + return + } + child.stdout?.on('data', (chunk: Buffer) => { + stdout = appendBounded(stdout, chunk) + }) + child.stderr?.on('data', (chunk: Buffer) => { + stderr = appendBounded(stderr, chunk) + }) + child.on('error', (error: NodeJS.ErrnoException) => { + stderr = error.code === 'ENOENT' ? 'claude-cli-not-found' : error.message + finish(null) + }) + child.on('exit', (code) => finish(code)) + }) +} + +export async function claudeSubscriptionStatus(options: { + binaryPath?: string + spawnFn?: SpawnFn + timeoutMs?: number + credentialsPath?: string +} = {}): Promise { + const credentialsPath = + options.credentialsPath ?? join(homedir(), '.claude', '.credentials.json') + const fallback = (): ClaudeSubscriptionStatus => + existsSync(credentialsPath) + ? { loggedIn: true, source: 'credentials-file', message: 'cli-status-unavailable' } + : { loggedIn: false, source: 'none', message: 'not-logged-in' } + const result = await runCapturedCommand({ + binaryPath: options.binaryPath, + args: ['auth', 'status', '--json'], + spawnFn: options.spawnFn, + timeoutMs: options.timeoutMs ?? DEFAULT_STATUS_TIMEOUT_MS + }) + if (result.timedOut) { + const status = fallback() + return status.loggedIn ? status : { ...status, message: 'status-timeout' } + } + if (result.code !== 0) { + const status = fallback() + if (status.loggedIn) return status + const message = redactClaudeAuthText(result.stderr || result.stdout) + return { ...status, message: message || 'cli-status-unavailable' } + } + try { + const parsed = JSON.parse(result.stdout) as { loggedIn?: unknown } + return parsed.loggedIn === true + ? { loggedIn: true, source: 'cli' } + : { loggedIn: false, source: 'cli', message: 'not-logged-in' } + } catch { + const status = fallback() + return status.loggedIn ? status : { ...status, message: 'invalid-status-response' } + } } /** @@ -61,26 +204,41 @@ export function resolveBundledClaudeBinary(kunRoots: readonly string[]): string } /** - * Run `claude setup-token`, opening the user's browser for OAuth, and resolve - * with the captured token. Defensive: a missing CLI, timeout, or non-zero exit - * all resolve to `{ ok:false }` so the UI can fall back to manual paste. - * `spawnFn` is injectable for tests. + * Run the official ambient subscription login and poll the official structured + * status. Polling makes completion independent from terminal rendering and + * lets the GUI finish even if the helper process remains open after OAuth. */ -export function runClaudeSetupToken( - options: { spawnFn?: typeof spawn; timeoutMs?: number; binaryPath?: string } = {} +export async function runClaudeSubscriptionLogin( + options: { + spawnFn?: SpawnFn + timeoutMs?: number + pollIntervalMs?: number + binaryPath?: string + status?: () => Promise + } = {} ): Promise { const spawnFn = options.spawnFn ?? spawn - const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000 + const timeoutMs = options.timeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS + const pollIntervalMs = options.pollIntervalMs ?? LOGIN_POLL_INTERVAL_MS + const readStatus = options.status ?? (() => claudeSubscriptionStatus({ + binaryPath: options.binaryPath, + spawnFn + })) + if ((await readStatus()).loggedIn) return { ok: true, mode: 'ambient' } + return new Promise((resolve) => { let settled = false - let timer: ReturnType | undefined + let timeout: ReturnType | undefined + let pollTimer: ReturnType | undefined let child: ChildProcess | undefined - let buffer = '' + let output = '' + let polling = false const done = (result: ClaudeSubscriptionLoginResult): void => { if (settled) return settled = true - if (timer) clearTimeout(timer) + if (timeout) clearTimeout(timeout) + if (pollTimer) clearTimeout(pollTimer) try { child?.kill() } catch { @@ -89,33 +247,36 @@ export function runClaudeSetupToken( resolve(result) } - const captureToken = (): boolean => { - const match = buffer.match(OAUTH_TOKEN_PATTERN) - if (match) { - done({ ok: true, token: match[0] }) - return true + const poll = async (): Promise => { + if (settled || polling) return + polling = true + try { + if ((await readStatus()).loggedIn) { + done({ ok: true, mode: 'ambient' }) + return + } + } catch { + // keep polling until the bounded login deadline + } finally { + polling = false } - return false + if (!settled) pollTimer = setTimeout(() => void poll(), pollIntervalMs) } try { - // Prefer the SDK's bundled binary (no separate install); fall back to a - // `claude` on PATH only when it couldn't be resolved. - child = spawnFn(options.binaryPath ?? 'claude', ['setup-token'], { + child = spawnFn(options.binaryPath ?? 'claude', ['auth', 'login', '--claudeai'], { stdio: ['ignore', 'pipe', 'pipe'], shell: !options.binaryPath && process.platform === 'win32', - env: process.env + env: claudeAuthEnv() }) } catch (err) { done({ ok: false, message: err instanceof Error ? err.message : 'failed to start claude' }) return } - timer = setTimeout(() => done({ ok: false, message: 'timeout' }), timeoutMs) - + timeout = setTimeout(() => done({ ok: false, message: 'timeout' }), timeoutMs) const onChunk = (chunk: Buffer): void => { - buffer += chunk.toString() - captureToken() + output = appendBounded(output, chunk) } child.stdout?.on('data', onChunk) child.stderr?.on('data', onChunk) @@ -125,9 +286,70 @@ export function runClaudeSetupToken( message: err.code === 'ENOENT' ? 'claude-cli-not-found' : err.message }) }) - child.on('exit', (code) => { - if (captureToken()) return - done({ ok: false, message: buffer.trim().slice(-300) || `claude setup-token exited (code ${code})` }) + child.on('exit', () => { + void readStatus().then((status) => { + if (status.loggedIn) { + done({ ok: true, mode: 'ambient' }) + return + } + done({ + ok: false, + message: redactClaudeAuthText(output) || 'claude-login-exited' + }) + }).catch(() => { + done({ ok: false, message: redactClaudeAuthText(output) || 'claude-login-exited' }) + }) }) + pollTimer = setTimeout(() => void poll(), Math.min(pollIntervalMs, 100)) }) } + +/** Make a real, no-tools request so connection success proves upstream auth. */ +export async function probeClaudeSubscription(options: { + token?: string + binaryPath?: string + spawnFn?: SpawnFn + timeoutMs?: number + now?: () => number +} = {}): Promise { + const rawToken = options.token?.trim() ?? '' + let token: string | undefined + if (rawToken) { + const validation = validateClaudeSubscriptionToken(rawToken) + if (!validation.ok) return validation + token = validation.token + } + const now = options.now ?? Date.now + const startedAt = now() + const result = await runCapturedCommand({ + binaryPath: options.binaryPath, + args: [ + '-p', + 'Reply exactly KUN_AUTH_OK', + '--model', + 'haiku', + '--max-turns', + '1', + '--no-session-persistence', + '--output-format', + 'json', + '--permission-mode', + 'plan', + '--disable-slash-commands', + '--tools', + '' + ], + env: claudeAuthEnv(token), + spawnFn: options.spawnFn, + timeoutMs: options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS + }) + if (result.timedOut) return { ok: false, message: 'probe-timeout' } + if (result.code === 0) { + return { ok: true, latencyMs: Math.max(0, now() - startedAt) } + } + const message = redactClaudeAuthText(result.stderr || result.stdout, token) + return { + ok: false, + message: message || (result.code === null ? 'claude-cli-not-found' : `claude-probe-exited-${result.code}`) + } +} diff --git a/src/main/claw-runtime.test.ts b/src/main/claw-runtime.test.ts index b8f34d2ff..374a69237 100644 --- a/src/main/claw-runtime.test.ts +++ b/src/main/claw-runtime.test.ts @@ -34,7 +34,7 @@ function buildSettings(): AppSettingsV1 { workspaceRoot: '/tmp/workspace', conversationWorkspaceRoot: '~/Documents/Kun', log: { enabled: true, retentionDays: 7 }, - checkpointCleanup: { enabled: false, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, notifications: { turnComplete: true }, appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, keyboardShortcuts: defaultKeyboardShortcuts(), diff --git a/src/main/claw-schedule-mcp-config.test.ts b/src/main/claw-schedule-mcp-config.test.ts index 262f983e7..47845134a 100644 --- a/src/main/claw-schedule-mcp-config.test.ts +++ b/src/main/claw-schedule-mcp-config.test.ts @@ -46,7 +46,7 @@ function createSettings(patch: Partial = enabled: true, retentionDays: 2 }, - checkpointCleanup: { enabled: false, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, notifications: { turnComplete: true }, diff --git a/src/main/claw-scheduled-task-detector.test.ts b/src/main/claw-scheduled-task-detector.test.ts index 0f6cb1ac5..219a3c99e 100644 --- a/src/main/claw-scheduled-task-detector.test.ts +++ b/src/main/claw-scheduled-task-detector.test.ts @@ -37,7 +37,7 @@ function settings(endpointFormat: ModelEndpointFormat): AppSettingsV1 { workspaceRoot: '/tmp/workspace', conversationWorkspaceRoot: '~/Documents/Kun', log: { enabled: false, retentionDays: 7 }, - checkpointCleanup: { enabled: false, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, notifications: { turnComplete: true }, appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, keyboardShortcuts: defaultKeyboardShortcuts(), diff --git a/src/main/codex-auth.test.ts b/src/main/codex-auth.test.ts index 3e5196ec9..7b1ecda55 100644 --- a/src/main/codex-auth.test.ts +++ b/src/main/codex-auth.test.ts @@ -1,6 +1,7 @@ import { createServer, get as httpGet, type Server } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' -import { startCodexBrowserAuth } from './codex-auth' +import { CODEX_CLI_VERSION } from '../../kun/src/adapters/model/provider-cli-identity.js' +import { codexRequestHeaders, codexUserAgent, startCodexBrowserAuth } from './codex-auth' const CODEX_OAUTH_PORTS = [1455, 1457] as const const CODEX_OAUTH_SCOPE = 'openid profile email offline_access api.connectors.read api.connectors.invoke' @@ -137,6 +138,22 @@ describe('startCodexBrowserAuth', () => { } }) + it('builds Codex CLI-shaped request headers without Kun or DeepSeek-GUI branding', () => { + const headers = codexRequestHeaders({ + kind: 'codex-oauth', + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + accountId: 'acct_1' + }) + expect(headers.originator).toBe('codex_cli_rs') + expect(headers['User-Agent']).toBe(codexUserAgent()) + expect(headers['User-Agent']).toMatch( + new RegExp(`^codex_cli_rs\\/${CODEX_CLI_VERSION.replace(/\./g, '\\.')} \\(.+; .+\\)$`) + ) + expect(headers['User-Agent']).not.toMatch(/deepseekgui|kun/i) + }) + it('includes token endpoint error details when the exchange is rejected', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { return new Response(JSON.stringify({ diff --git a/src/main/codex-auth.ts b/src/main/codex-auth.ts index 024e6beb2..7394da238 100644 --- a/src/main/codex-auth.ts +++ b/src/main/codex-auth.ts @@ -1,5 +1,11 @@ import { createServer, type Server } from 'node:http' import { createHash, randomBytes, randomUUID } from 'node:crypto' +import { + CODEX_CLI_ORIGINATOR, + CODEX_CLI_VERSION, + codexCliRequestHeaders, + codexCliUserAgent +} from '../../kun/src/adapters/model/provider-cli-identity.js' import { grokRequestHeaders } from './grok-auth' const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' @@ -11,7 +17,8 @@ const CODEX_OAUTH_HOST = '127.0.0.1' const CODEX_OAUTH_TIMEOUT_MS = 5 * 60 * 1000 const CODEX_SESSION_ID = randomUUID() const CODEX_OAUTH_SCOPE = 'openid profile email offline_access api.connectors.read api.connectors.invoke' -const CODEX_ORIGINATOR = 'codex_cli_rs' +const CODEX_ORIGINATOR = CODEX_CLI_ORIGINATOR +export { CODEX_CLI_VERSION, codexCliUserAgent as codexUserAgent } export type CodexOAuthCredentials = { kind: 'codex-oauth' @@ -452,13 +459,10 @@ export function encodeCodexCredentials(creds: CodexOAuthCredentials): string { } export function codexRequestHeaders(creds: CodexOAuthCredentials): Record { - return { - 'ChatGPT-Account-Id': creds.accountId, - originator: CODEX_ORIGINATOR, - 'OpenAI-Beta': 'responses=experimental', - 'User-Agent': `${CODEX_ORIGINATOR}/0.0.0 (deepseekgui)`, - session_id: CODEX_SESSION_ID - } + return codexCliRequestHeaders({ + accountId: creds.accountId, + sessionId: CODEX_SESSION_ID + }) } export function resolveCodexOAuthApiKey(rawApiKey: string): { apiKey: string; headers?: Record } { diff --git a/src/main/credential-recovery.test.ts b/src/main/credential-recovery.test.ts new file mode 100644 index 000000000..373e564ba --- /dev/null +++ b/src/main/credential-recovery.test.ts @@ -0,0 +1,134 @@ +import { mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WINDOWS_DPAPI_KEY_PREFIX } from '../../kun/src/security/secret-store.js' +import { resetUnreadableWindowsCredentials } from './credential-recovery' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('resetUnreadableWindowsCredentials', () => { + it('backs up credential state while preserving conversations and ordinary data', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'kun-credential-recovery-')) + roots.push(dataDir) + const files = [ + ['secret.key', `${WINDOWS_DPAPI_KEY_PREFIX}broken`], + ['credentials/credentials.enc.json', '{"credentials":{"credential":{}}}'], + ['mcp-oauth/google.json', '{"tokens":"encrypted"}'], + ['extensions/accounts.json', '{"accounts":{}}'], + ['extensions/provider-bindings.json', '{"bindings":{}}'], + ['extensions/legacy-credential-migrations.json', '{"entries":{}}'], + ['threads/thread-1.json', '{"title":"keep me"}'] + ] as const + for (const [relativePath, content] of files) { + const path = join(dataDir, relativePath) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, content) + } + + const result = await resetUnreadableWindowsCredentials(dataDir, { + run: vi.fn(async () => ({ code: 1, stdout: '', stderr: 'CryptUnprotectData failed' })), + now: () => new Date('2026-07-25T00:00:00.000Z'), + id: () => 'test-recovery' + }) + + expect(result.backupPath).toBe(join( + dataDir, + 'credential-recovery', + '2026-07-25T00-00-00-000Z-test-recovery' + )) + expect(result.movedItems).toEqual([ + 'secret.key', + 'credentials', + 'mcp-oauth', + 'extensions/accounts.json', + 'extensions/provider-bindings.json', + 'extensions/legacy-credential-migrations.json' + ]) + await expect(readFile(join(result.backupPath, 'secret.key'), 'utf8')) + .resolves.toBe(`${WINDOWS_DPAPI_KEY_PREFIX}broken`) + await expect(stat(join(dataDir, 'secret.key'))).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(readFile(join(dataDir, 'threads/thread-1.json'), 'utf8')) + .resolves.toContain('keep me') + }) + + it('refuses to reset a DPAPI key that is still readable', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'kun-credential-recovery-')) + roots.push(dataDir) + const keyPath = join(dataDir, 'secret.key') + await writeFile(keyPath, `${WINDOWS_DPAPI_KEY_PREFIX}readable`) + + await expect(resetUnreadableWindowsCredentials(dataDir, { + run: vi.fn(async () => ({ + code: 0, + stdout: Buffer.alloc(32, 7).toString('base64'), + stderr: '' + })) + })).rejects.toThrow(/unnecessary/) + await expect(readFile(keyPath, 'utf8')).resolves.toBe(`${WINDOWS_DPAPI_KEY_PREFIX}readable`) + }) + + it('rolls back files already moved when the backup cannot complete', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'kun-credential-recovery-')) + roots.push(dataDir) + const keyPath = join(dataDir, 'secret.key') + const credentialsPath = join(dataDir, 'credentials', 'credentials.enc.json') + await writeFile(keyPath, `${WINDOWS_DPAPI_KEY_PREFIX}broken`) + await mkdir(dirname(credentialsPath), { recursive: true }) + await writeFile(credentialsPath, '{"credentials":{}}') + const backupPath = join( + dataDir, + 'credential-recovery', + '2026-07-25T00-00-00-000Z-rollback-test' + ) + + await expect(resetUnreadableWindowsCredentials(dataDir, { + run: vi.fn(async () => ({ code: 1, stdout: '', stderr: 'CryptUnprotectData failed' })), + now: () => new Date('2026-07-25T00:00:00.000Z'), + id: () => 'rollback-test', + move: async (sourcePath, destinationPath) => { + if (sourcePath === join(dataDir, 'credentials')) throw new Error('simulated move failure') + await rename(sourcePath, destinationPath) + } + })).rejects.toThrow(/simulated move failure/) + + await expect(readFile(keyPath, 'utf8')).resolves.toBe(`${WINDOWS_DPAPI_KEY_PREFIX}broken`) + await expect(readFile(credentialsPath, 'utf8')).resolves.toContain('credentials') + await expect(stat(backupPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('keeps the backup copy when rollback cannot restore a moved credential file', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'kun-credential-recovery-')) + roots.push(dataDir) + const keyPath = join(dataDir, 'secret.key') + const credentialsPath = join(dataDir, 'credentials', 'credentials.enc.json') + await writeFile(keyPath, `${WINDOWS_DPAPI_KEY_PREFIX}broken`) + await mkdir(dirname(credentialsPath), { recursive: true }) + await writeFile(credentialsPath, '{"credentials":{}}') + const backupPath = join( + dataDir, + 'credential-recovery', + '2026-07-25T00-00-00-000Z-retained-backup' + ) + + await expect(resetUnreadableWindowsCredentials(dataDir, { + run: vi.fn(async () => ({ code: 1, stdout: '', stderr: 'CryptUnprotectData failed' })), + now: () => new Date('2026-07-25T00:00:00.000Z'), + id: () => 'retained-backup', + move: async (sourcePath, destinationPath) => { + if (sourcePath === join(dataDir, 'credentials')) throw new Error('simulated backup failure') + if (sourcePath === join(backupPath, 'secret.key')) throw new Error('simulated rollback failure') + await rename(sourcePath, destinationPath) + } + })).rejects.toThrow(backupPath) + + await expect(stat(keyPath)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(readFile(join(backupPath, 'secret.key'), 'utf8')) + .resolves.toBe(`${WINDOWS_DPAPI_KEY_PREFIX}broken`) + await expect(readFile(credentialsPath, 'utf8')).resolves.toContain('credentials') + }) +}) diff --git a/src/main/credential-recovery.ts b/src/main/credential-recovery.ts new file mode 100644 index 000000000..d6db8c068 --- /dev/null +++ b/src/main/credential-recovery.ts @@ -0,0 +1,114 @@ +import { randomUUID } from 'node:crypto' +import { lstat, mkdir, readFile, rename, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { + createSecretEncryptor, + defaultSecretCommandRunner, + isUnreadableCredentialKeyError, + WINDOWS_DPAPI_KEY_PREFIX, + type CommandRunner +} from '../../kun/src/security/secret-store.js' + +const CREDENTIAL_RECOVERY_ITEMS = [ + 'secret.key', + 'credentials', + 'mcp-oauth', + 'extensions/accounts.json', + 'extensions/provider-bindings.json', + 'extensions/legacy-credential-migrations.json' +] as const + +export type CredentialRecoveryResult = { + backupPath: string + movedItems: string[] +} + +type CredentialRecoveryOptions = { + run?: CommandRunner + now?: () => Date + id?: () => string + move?: typeof rename +} + +export async function resetUnreadableWindowsCredentials( + dataDir: string, + options: CredentialRecoveryOptions = {} +): Promise { + const keyFilePath = join(dataDir, 'secret.key') + const keyFileText = await readFile(keyFilePath, 'utf8').catch(() => '') + if (!keyFileText.trim().startsWith(WINDOWS_DPAPI_KEY_PREFIX)) { + throw new Error('Credential recovery is unavailable because no DPAPI-protected Kun key was found.') + } + + let unreadable = false + try { + await createSecretEncryptor({ + keyFilePath, + platform: 'win32', + run: options.run ?? defaultSecretCommandRunner, + disableOsKeychain: false + }) + } catch (error) { + if (!isUnreadableCredentialKeyError(error)) throw error + unreadable = true + } + if (!unreadable) { + throw new Error('Credential recovery is unnecessary because the DPAPI-protected Kun key is readable.') + } + + const timestamp = (options.now?.() ?? new Date()).toISOString().replace(/[:.]/g, '-') + const backupPath = join( + dataDir, + 'credential-recovery', + `${timestamp}-${(options.id?.() ?? randomUUID()).replace(/[^A-Za-z0-9_-]/g, '')}` + ) + const movedItems: string[] = [] + const move = options.move ?? rename + + try { + await mkdir(backupPath, { recursive: true, mode: 0o700 }) + for (const relativePath of CREDENTIAL_RECOVERY_ITEMS) { + const sourcePath = join(dataDir, relativePath) + if (!(await pathExists(sourcePath))) continue + const destinationPath = join(backupPath, relativePath) + await mkdir(dirname(destinationPath), { recursive: true, mode: 0o700 }) + await move(sourcePath, destinationPath) + movedItems.push(relativePath) + } + } catch (error) { + const rollbackFailures: string[] = [] + for (const relativePath of [...movedItems].reverse()) { + const sourcePath = join(backupPath, relativePath) + const destinationPath = join(dataDir, relativePath) + await mkdir(dirname(destinationPath), { recursive: true, mode: 0o700 }).catch(() => undefined) + await move(sourcePath, destinationPath).catch((rollbackError) => { + rollbackFailures.push( + `${relativePath}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}` + ) + }) + } + if (rollbackFailures.length === 0) { + await rm(backupPath, { recursive: true, force: true }).catch(() => undefined) + } + throw new Error( + [ + `Failed to back up unreadable credentials: ${error instanceof Error ? error.message : String(error)}`, + ...(rollbackFailures.length > 0 + ? [`Rollback was incomplete; remaining backup data was kept at ${backupPath}. ${rollbackFailures.join('; ')}`] + : []) + ].join(' '), + { cause: error } + ) + } + + return { backupPath, movedItems } +} + +async function pathExists(path: string): Promise { + try { + await lstat(path) + return true + } catch (error) { + return !(typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') + } +} diff --git a/src/main/data-migration/export-inventory.test.ts b/src/main/data-migration/export-inventory.test.ts index b2df109ad..5c127d5c1 100644 --- a/src/main/data-migration/export-inventory.test.ts +++ b/src/main/data-migration/export-inventory.test.ts @@ -44,7 +44,7 @@ function settings(workspaceRoot: string, nestedRoot = workspaceRoot): AppSetting workspaceRoot, conversationWorkspaceRoot: workspaceRoot, log: { enabled: true, retentionDays: 7 }, - checkpointCleanup: { enabled: true, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: true, intervalDays: 3 }, notifications: { turnComplete: true }, appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, keyboardShortcuts: defaultKeyboardShortcuts(), diff --git a/src/main/data-migration/export-orchestrator.test.ts b/src/main/data-migration/export-orchestrator.test.ts index 3aea9bb8c..981945d10 100644 --- a/src/main/data-migration/export-orchestrator.test.ts +++ b/src/main/data-migration/export-orchestrator.test.ts @@ -40,7 +40,7 @@ function settings(workspaceRoot: string): AppSettingsV1 { workspaceRoot, conversationWorkspaceRoot: workspaceRoot, log: { enabled: false, retentionDays: 7 }, - checkpointCleanup: { enabled: false, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, notifications: { turnComplete: true }, appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, keyboardShortcuts: defaultKeyboardShortcuts(), diff --git a/src/main/execution-settings-consent.test.ts b/src/main/execution-settings-consent.test.ts index d31eb7a1a..0fb4ae61e 100644 --- a/src/main/execution-settings-consent.test.ts +++ b/src/main/execution-settings-consent.test.ts @@ -29,7 +29,7 @@ function settings(): AppSettingsV1 { workspaceRoot: '/tmp/workspace', conversationWorkspaceRoot: '~/Documents/Kun', log: { enabled: false, retentionDays: 7 }, - checkpointCleanup: { enabled: false, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, notifications: { turnComplete: true }, appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, keyboardShortcuts: defaultKeyboardShortcuts(), diff --git a/src/main/extensions/extension-media-protocol.test.ts b/src/main/extensions/extension-media-protocol.test.ts index 3ac667d2b..4a144070b 100644 --- a/src/main/extensions/extension-media-protocol.test.ts +++ b/src/main/extensions/extension-media-protocol.test.ts @@ -120,13 +120,14 @@ describe('kun-media protocol', () => { ]) }) - it('registers both Extension schemes in the one permitted pre-ready call', () => { + it('registers all custom schemes in the one permitted pre-ready call', () => { const registerSchemesAsPrivileged = vi.fn() registerKunExtensionPlatformSchemesAsPrivileged({ registerSchemesAsPrivileged } as never) expect(registerSchemesAsPrivileged).toHaveBeenCalledTimes(1) expect(registerSchemesAsPrivileged).toHaveBeenCalledWith([ expect.objectContaining({ scheme: 'kun-extension' }), - expect.objectContaining({ scheme: 'kun-media' }) + expect.objectContaining({ scheme: 'kun-media' }), + expect.objectContaining({ scheme: 'kun-workspace-preview' }) ]) }) diff --git a/src/main/extensions/extension-media-protocol.ts b/src/main/extensions/extension-media-protocol.ts index eeaa254b9..9ea376613 100644 --- a/src/main/extensions/extension-media-protocol.ts +++ b/src/main/extensions/extension-media-protocol.ts @@ -14,6 +14,7 @@ import type { ExtensionViewSessionRegistry } from './extension-view-sessions' import { KUN_EXTENSION_PRIVILEGED_SCHEME } from './extension-resource-protocol' +import { KUN_WORKSPACE_PREVIEW_PRIVILEGED_SCHEME } from '../services/workspace-preview-protocol' export const KUN_MEDIA_SCHEME = 'kun-media' @@ -124,7 +125,8 @@ export function registerKunMediaSchemeAsPrivileged(protocol: SchemeRegistrar): v export function registerKunExtensionPlatformSchemesAsPrivileged(protocol: SchemeRegistrar): void { protocol.registerSchemesAsPrivileged([ KUN_EXTENSION_PRIVILEGED_SCHEME, - KUN_MEDIA_PRIVILEGED_SCHEME + KUN_MEDIA_PRIVILEGED_SCHEME, + KUN_WORKSPACE_PREVIEW_PRIVILEGED_SCHEME ]) } diff --git a/src/main/gemini-cli-subscription.test.ts b/src/main/gemini-cli-subscription.test.ts new file mode 100644 index 000000000..9b50e432d --- /dev/null +++ b/src/main/gemini-cli-subscription.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { geminiCliSubscriptionModels } from './gemini-cli-subscription' + +describe('geminiCliSubscriptionModels', () => { + it('returns the direct Gemini CLI API catalog without Antigravity-only ids', () => { + expect(geminiCliSubscriptionModels()).toEqual([ + 'gemini-3.1-pro-preview', + 'gemini-3-flash-preview', + 'gemini-3.1-flash-lite', + 'gemini-2.5-pro', + 'gemini-2.5-flash' + ]) + expect(geminiCliSubscriptionModels()).not.toContain('gemini-3.6-flash') + }) +}) diff --git a/src/main/gemini-cli-subscription.ts b/src/main/gemini-cli-subscription.ts new file mode 100644 index 000000000..7b8175bf6 --- /dev/null +++ b/src/main/gemini-cli-subscription.ts @@ -0,0 +1,85 @@ +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { delimiter, join } from 'node:path' +import { promisify } from 'node:util' +import { GEMINI_CLI_SUBSCRIPTION_MODEL_IDS } from '../shared/model-provider-presets' + +const execFileAsync = promisify(execFile) + +export type GeminiCliSubscriptionStatus = { + installed: boolean + authenticated: boolean + path?: string + credentialSource?: 'keychain' | 'file' +} + +export function resolveGeminiCliBinary(): string | undefined { + const executable = process.platform === 'win32' ? 'gemini.cmd' : 'gemini' + const pathCandidates = (process.env.PATH ?? '') + .split(delimiter) + .filter(Boolean) + .map((directory) => join(directory, executable)) + const candidates = [ + ...pathCandidates, + join(homedir(), '.local', 'bin', executable), + ...(process.platform === 'darwin' + ? [ + join('/opt/homebrew/bin', executable), + join('/usr/local/bin', executable) + ] + : process.platform === 'win32' + ? [] + : [join('/usr/local/bin', executable), join('/usr/bin', executable)]) + ] + return candidates.find((candidate) => existsSync(candidate)) +} + +export async function geminiCliSubscriptionStatus(): Promise { + const binaryPath = resolveGeminiCliBinary() + const legacyCredentialPath = join(homedir(), '.gemini', 'oauth_creds.json') + const keychain = process.platform === 'darwin' + ? await hasMacGeminiCliCredential() + : false + const file = existsSync(legacyCredentialPath) + return { + installed: Boolean(binaryPath), + authenticated: keychain || file, + ...(binaryPath ? { path: binaryPath } : {}), + ...(keychain + ? { credentialSource: 'keychain' as const } + : file + ? { credentialSource: 'file' as const } + : {}) + } +} + +export function geminiCliSubscriptionModels(): string[] { + return [...GEMINI_CLI_SUBSCRIPTION_MODEL_IDS] +} + +async function hasMacGeminiCliCredential(): Promise { + try { + const { stdout } = await execFileAsync('/usr/bin/security', [ + 'find-generic-password', + '-s', + 'gemini-cli-oauth', + '-a', + 'main-account', + '-w' + ], { + encoding: 'utf8', + timeout: 5_000, + maxBuffer: 512 * 1024 + }) + const parsed = JSON.parse(stdout.trim()) as { + token?: { accessToken?: unknown; refreshToken?: unknown } + } + return Boolean( + typeof parsed.token?.accessToken === 'string' || + typeof parsed.token?.refreshToken === 'string' + ) + } catch { + return false + } +} diff --git a/src/main/grok-auth.ts b/src/main/grok-auth.ts index 6a8b98195..fadf69363 100644 --- a/src/main/grok-auth.ts +++ b/src/main/grok-auth.ts @@ -1,14 +1,20 @@ import { createServer, type Server } from 'node:http' import { createHash, randomBytes } from 'node:crypto' +import { + GROK_CLI_TOKEN_AUTH, + GROK_CLI_VERSION, + grokCliMediaHeaders, + grokCliProxyHeaders +} from '../../kun/src/adapters/model/provider-cli-identity.js' /** Matches the public Grok CLI OAuth client (xai-grok-shell GrokComConfig::default). */ export const GROK_OAUTH_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828' export const GROK_OAUTH_ISSUER = 'https://auth.x.ai' export const GROK_CLI_CHAT_PROXY_BASE_URL = 'https://cli-chat-proxy.grok.com/v1' -export const GROK_TOKEN_AUTH_HEADER = 'xai-grok-cli' +export const GROK_TOKEN_AUTH_HEADER = GROK_CLI_TOKEN_AUTH export const GROK_OAUTH_REFERRER = 'kun' /** Keep aligned with the Grok Build client whose public OAuth contract we use. */ -export const GROK_CLIENT_VERSION = '0.2.106' +export const GROK_CLIENT_VERSION = GROK_CLI_VERSION /** Align with grok-build DEFAULT_EARLY_INVALIDATION_SECS. */ export const GROK_EARLY_INVALIDATION_MS = 5 * 60 * 1000 @@ -564,12 +570,7 @@ export function encodeGrokCredentials(creds: GrokOAuthCredentials): string { } export function grokRequestHeaders(): Record { - return { - 'X-XAI-Token-Auth': GROK_TOKEN_AUTH_HEADER, - 'x-authenticateresponse': 'authenticate-response', - 'x-grok-client-version': GROK_CLIENT_VERSION, - 'x-grok-client-mode': 'interactive' - } + return grokCliProxyHeaders() } /** @@ -577,11 +578,7 @@ export function grokRequestHeaders(): Record { * Proxy-only authentication headers must not be forwarded to this endpoint. */ export function grokMediaRequestHeaders(): Record { - return { - 'User-Agent': `xai-grok-build/${GROK_CLIENT_VERSION}`, - 'x-grok-client-version': GROK_CLIENT_VERSION, - 'x-grok-client-identifier': 'kun' - } + return grokCliMediaHeaders() } /** diff --git a/src/main/gui-updater.test.ts b/src/main/gui-updater.test.ts index 09e929319..600311f50 100644 --- a/src/main/gui-updater.test.ts +++ b/src/main/gui-updater.test.ts @@ -236,7 +236,7 @@ describe('installGuiUpdate', () => { expect(setUpdateInstallQuitting.mock.invocationCallOrder[0]).toBeLessThan( updater.quitAndInstall.mock.invocationCallOrder[0] ) - expect(updater.quitAndInstall).toHaveBeenCalledWith(false, true) + expect(updater.quitAndInstall).toHaveBeenCalledWith(true, true) }) it('reuses the same cleanup when the native updater emits before-quit-for-update', async () => { @@ -273,7 +273,7 @@ describe('installGuiUpdate', () => { await expect(installing).resolves.toEqual({ ok: true }) expect(setUpdateInstallQuitting).toHaveBeenCalledTimes(2) expect(setUpdateInstallQuitting).toHaveBeenLastCalledWith(true) - expect(updater.quitAndInstall).toHaveBeenCalledWith(false, true) + expect(updater.quitAndInstall).toHaveBeenCalledWith(true, true) }) it('clears the update quit marker when quitAndInstall throws synchronously', async () => { diff --git a/src/main/gui-updater.ts b/src/main/gui-updater.ts index 22c717e0e..bf34787de 100644 --- a/src/main/gui-updater.ts +++ b/src/main/gui-updater.ts @@ -780,7 +780,12 @@ export async function installGuiUpdate(): Promise { await Promise.all([pendingVersionStateWrite, runBeforeInstallUpdate()]) markUpdateInstallQuitting(true) updateInstallQuitMarked = true - autoUpdater.quitAndInstall(false, true) + // In-app updates must stay silent on Windows. The assisted NSIS UI can + // surface its old-uninstaller retry dialog even though our overwrite + // fallback can safely continue; silent mode applies that dialog's default + // cancel action instead of asking the user to make the counter-intuitive + // choice. Manually launched installers remain interactive. + autoUpdater.quitAndInstall(true, true) return { ok: true } } catch (e) { if (updateInstallQuitMarked) markUpdateInstallQuitting(false) diff --git a/src/main/index.ts b/src/main/index.ts index 9364c1be6..c928f55e9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -38,7 +38,11 @@ import { configureAppIdentity } from './app-identity' import { shouldStartHidden, syncLoginItemSettings } from './desktop-behavior' import { resolveLogDirectory, resolveNamedPreloadPath, resolvePreloadPath } from './main-paths' import { runLegacyKunDataMigration } from './legacy-data-migration' -import { LegacyProviderSettingsMigrationCoordinator } from './legacy-provider-settings-migration' +import { + LegacyProviderSettingsMigrationCoordinator, + resolveSettingsDataDir +} from './legacy-provider-settings-migration' +import { resetUnreadableWindowsCredentials } from './credential-recovery' import { applyKunRuntimePatch, kunSettingsEnvelope, @@ -91,6 +95,7 @@ import { } from './kun-process' import { expandHomePath } from './settings-store' import { KunRuntimeSupervisor, type KunRuntimeStatus } from './kun-runtime-supervisor' +import { managedKunHostCanAutoStart } from './managed-runtime-startup-policy' import { configureLogger, logError, logInfo, logWarn, pruneOnStartup } from './logger' import { cleanupUnusedGitCheckpointsIfDue } from './services/git-checkpoint-service' import { resolveMainWindowCloseDecision } from './window-close-behavior' @@ -171,6 +176,7 @@ import { startExtensionSecretRevealConsentPump, type RegisterExtensionIpcHandlersOptions } from './ipc/register-extension-ipc-handlers' +import { WorkspacePreviewProtocolRegistry } from './services/workspace-preview-protocol' const __dirname = dirname(fileURLToPath(import.meta.url)) registerKunExtensionPlatformSchemesAsPrivileged(protocol) @@ -321,34 +327,48 @@ function setUpdateInstallQuitting(active: boolean): void { runtimeShutdown.setUpdateInstallQuit(active) } -async function runCheckpointCleanupIfDue(settings: AppSettingsV1): Promise { - if (!settings.checkpointCleanup.enabled) return +async function runCheckpointCleanup( + settings: AppSettingsV1, + options: { force?: boolean; reason?: string } = {} +): Promise { + const force = options.force === true + const reason = options.reason ?? (force ? 'forced' : 'interval') + // Startup / upgrade retention always runs. The settings toggle only gates the + // periodic background timer so a previous "cleanup off" cannot leave gigabytes + // of stale checkpoints behind after relaunch or app update. + if (!force && !settings.checkpointCleanup.enabled) return const runtime = resolveKunRuntimeSettings(settings) const dataDir = resolveKunDataDir(runtime) const intervalDays = settings.checkpointCleanup.intervalDays const checkpointsRoot = settings.checkpointCleanup.directory?.trim() ? expandHomePath(settings.checkpointCleanup.directory.trim()) : undefined + const maxPerThread = settings.checkpointCleanup.maxPerThread try { const cleanup = await cleanupUnusedGitCheckpointsIfDue({ dataDir, intervalDays, - ...(checkpointsRoot ? { checkpointsRoot } : {}) + appVersion: app.getVersion(), + ...(force ? { force: true } : {}), + ...(checkpointsRoot ? { checkpointsRoot } : {}), + ...(maxPerThread !== undefined ? { maxPerThread } : {}) }) if (!cleanup.due) return const { result } = cleanup console.info( - `[kun-gui] git checkpoint cleanup scanned=${result.scanned} deleted=${result.deleted} kept=${result.kept} failed=${result.failed}` + `[kun-gui] git checkpoint cleanup reason=${reason} scanned=${result.scanned} deleted=${result.deleted} kept=${result.kept} failed=${result.failed}` ) if (result.failed > 0) { logWarn('git-checkpoint-cleanup', 'failed to delete some unused checkpoints', { failed: result.failed, - failedIds: result.failedIds + failedIds: result.failedIds, + reason }) } } catch (error) { logWarn('git-checkpoint-cleanup', 'failed to clean unused checkpoints', { - message: error instanceof Error ? error.message : String(error) + message: error instanceof Error ? error.message : String(error), + reason }) } } @@ -357,11 +377,11 @@ function syncCheckpointCleanupTimer(settings: AppSettingsV1): void { stopCheckpointCleanupTimer() if (!settings.checkpointCleanup.enabled) return const intervalMs = settings.checkpointCleanup.intervalDays * 24 * 60 * 60 * 1_000 - const run = (): void => { - void runCheckpointCleanupIfDue(settings) - } - run() - checkpointCleanupTimer = setInterval(run, intervalMs) + // Interval / version-upgrade passes only. The forced startup pass is scheduled + // earlier in app.whenReady so retention does not wait on the interval gate. + checkpointCleanupTimer = setInterval(() => { + void runCheckpointCleanup(settings, { reason: 'interval' }) + }, intervalMs) checkpointCleanupTimer.unref?.() } @@ -813,9 +833,7 @@ function publishRuntimeSettingsSyncStatus( const runtimeSupervisor = new KunRuntimeSupervisor({ deps: { loadSettings: () => store.load(), - canAutoRestart: (settings) => Boolean( - resolveConfiguredApiKey(settings) && getKunRuntimeSettings(settings).autoStart - ), + canAutoRestart: managedKunHostCanAutoStart, ensureRuntime: (settings) => ensureRuntime(settings), restartRuntime: (settings) => restartRuntime(settings), checkHealth: (settings, timeoutMs) => kunRuntimeHealthMonitor.waitForHealthy(settings, timeoutMs), @@ -1028,7 +1046,6 @@ async function ensureKunRuntime(settings: AppSettingsV1): Promise } const runtime = getKunRuntimeSettings(currentSettings) - const hasApiKey = Boolean(resolveConfiguredApiKey(currentSettings)) const healthy = await kunRuntimeHealthMonitor.waitForHealthy(currentSettings, 2_000) if (healthy) { @@ -1040,12 +1057,6 @@ async function ensureKunRuntime(settings: AppSettingsV1): Promise throw runtimeJsonError(threadApi.error, threadApi.message) } - if (!hasApiKey) { - throw runtimeJsonError( - 'missing_api_key', - 'DeepSeek API Key is required before the GUI can start Kun.' - ) - } if (!runtime.autoStart) { throw runtimeJsonError( 'runtime_offline', @@ -1121,12 +1132,6 @@ async function restartRuntimeOnce(settings: AppSettingsV1): Promise { await waitForKunStartupSettled() const runtime = getKunRuntimeSettings(settings) - if (!resolveConfiguredApiKey(settings)) { - throw runtimeJsonError( - 'missing_api_key', - 'DeepSeek API Key is required before the GUI can start Kun.' - ) - } if (!runtime.autoStart) { throw runtimeJsonError( 'runtime_offline', @@ -1459,33 +1464,13 @@ async function restartManagedRuntimeForSettingsChange( if (!wasRunning) return { state: 'unavailable', message: 'Kun Runtime is not running.' } - // Decide BEFORE stopping the child. Stranding a healthy runtime is exactly - // issue #329: a partial/transient save (e.g. the active providerId moved to - // a profile whose key lives elsewhere) can momentarily resolve to "no API - // key" even though the user clearly has one configured. If the runtime we - // are about to restart was healthy and the previous settings had a usable - // key, don't kill it on the strength of a key check the new settings fail — - // leave it running on its current config; the next save with a resolvable - // key restarts cleanly. - const nextHasApiKey = Boolean(resolveConfiguredApiKey(next)) - if (!nextHasApiKey && Boolean(resolveConfiguredApiKey(prev))) { - logWarn( - 'settings-apply', - 'Skipping Kun restart: the new settings resolve to no API key but the running runtime had one — leaving the healthy runtime in place.' - ) - return { - state: 'failed', - message: 'Kun Runtime kept the previous provider configuration because the new credentials are unavailable.' - } - } - await waitForManagedRuntimeReadyBeforeStop(prev, 'settings-apply') await adapter.stopAndWait() - if (!nextHasApiKey || !runtime.autoStart) { + if (!runtime.autoStart) { publishRuntimeStatus({ state: 'stopped', source: 'settings-apply', - message: 'Kun was stopped: the new settings have no API key or auto-start is disabled.' + message: 'Kun was stopped because automatic startup is disabled.' }) return { state: 'unavailable', message: 'Kun Runtime is stopped by the current settings.' } } @@ -1533,7 +1518,7 @@ async function rollbackRuntimeSettingsAfterFailedApply( message: error instanceof Error ? error.message : String(error) }) } - if (!resolveConfiguredApiKey(base) || !getKunRuntimeSettings(base).autoStart) { + if (!getKunRuntimeSettings(base).autoStart) { publishRuntimeStatus({ state: 'stopped', source: 'settings-apply', @@ -1582,7 +1567,7 @@ async function restartManagedRuntimeForMcpConfigChange( if (!wasRunning) return { state: 'unavailable', message: 'Kun Runtime is not running.' } await waitForManagedRuntimeReadyBeforeStop(settings, 'mcp-config') await adapter.stopAndWait() - if (!resolveConfiguredApiKey(settings) || !runtime.autoStart) { + if (!runtime.autoStart) { return { state: 'unavailable', message: 'Kun Runtime is stopped by the current settings.' } } @@ -1669,12 +1654,15 @@ app.whenReady().then(async () => { app.dock?.setIcon(macDockIcon.isEmpty() ? appIcon : macDockIcon) } - store = new JsonSettingsStore(app.getPath('userData'), { - credentialMigration: new LegacyProviderSettingsMigrationCoordinator() - }) + const credentialMigration = new LegacyProviderSettingsMigrationCoordinator() + store = new JsonSettingsStore(app.getPath('userData'), { credentialMigration }) traceStartup('settings load:start') const initial = await store.load() traceStartup('settings load:done') + // Retention always runs at startup (and again after version upgrades inside + // IfDue). Fire-and-forget: must not block window creation. + void runCheckpointCleanup(initial, { force: true, reason: 'startup' }) + traceStartup('git checkpoint cleanup scheduled') const extensionDescriptors = new ExtensionDescriptorResolver(async (path, method, body) => { const settings = await store.load() return runtimeRequest(settings, path, { method, body }) @@ -1689,6 +1677,8 @@ app.whenReady().then(async () => { }) } registerExtensionProtocol(protocol) + const workspacePreviewProtocols = new WorkspacePreviewProtocolRegistry() + workspacePreviewProtocols.register(protocol) const extensionProtocolForPartition = (partition: string) => session.fromPartition(partition).protocol const extensionMediaProtocols = new ExtensionMediaProtocolRegistry({ @@ -1893,6 +1883,12 @@ app.whenReady().then(async () => { getMainWindow: () => mainWindow, applySettingsPatch, saveSettingsPatch, + resetUnreadableCredentials: async () => { + const dataDir = resolveSettingsDataDir(await store.load()) + const result = await resetUnreadableWindowsCredentials(dataDir) + credentialMigration.invalidateRuntime(dataDir) + return { reset: true as const, ...result } + }, runtimeRequest: async (path, method, body, headers) => { const settings = await store.load() return runtimeRequest(settings, path, { method, body, headers }) @@ -1924,7 +1920,8 @@ app.whenReady().then(async () => { readGuiUpdateState, loadGuiUpdaterModule, resolveLogDirectory: () => resolveLogDirectory(app), - logError + logError, + workspacePreviewProtocols }) const dataMigrationController = new DataMigrationController({ userDataPath: app.getPath('userData'), @@ -2040,7 +2037,7 @@ app.whenReady().then(async () => { console.warn('[kun-gui] prune logs:', err) }) - if (resolveConfiguredApiKey(initial)) { + if (managedKunHostCanAutoStart(initial)) { setTimeout(() => { void kunRuntimeAdapter.resolveExecutable(initial).catch((err) => { console.warn('[kun-gui] prewarm Kun binary:', err) diff --git a/src/main/ipc/app-ipc-schemas/settings.ts b/src/main/ipc/app-ipc-schemas/settings.ts index caaf539fd..8d2037894 100644 --- a/src/main/ipc/app-ipc-schemas/settings.ts +++ b/src/main/ipc/app-ipc-schemas/settings.ts @@ -13,6 +13,7 @@ import { MAX_WRITE_AUTOSAVE_DELAY_MS, MIN_WRITE_AUTOSAVE_DELAY_MS, MIN_KUN_LOCAL_PORT, + KUN_CONTEXT_COMPACTION_DEFAULTS_VERSION, SCHEDULE_MODEL_IDS, SCHEDULE_REASONING_EFFORT_IDS, SPEECH_TO_TEXT_PROTOCOLS, @@ -139,7 +140,14 @@ const modelProviderPatchSchema = z.object({ initialDelayMs: z.number().int().min(0).max(600_000).optional(), httpStatusCodes: z.array(z.number().int().min(400).max(599)).max(64).optional() }).strict().optional(), - kind: z.enum(['http', 'agent-sdk', 'antigravity-cli', 'cursor-sdk', 'gemini-code-assist']).optional(), + kind: z.enum([ + 'http', + 'agent-sdk', + 'antigravity-cli', + 'gemini-cli-api', + 'cursor-sdk', + 'gemini-code-assist' + ]).optional(), // Some third-party aggregators (litellm, oneapi, …) advertise 500+ chat // models in a single /v1/models response. The previous 200/50 caps caused // settings:set to silently fail with no toast (#397). Raised to leave @@ -302,6 +310,7 @@ const kunRuntimePatchSchema = z.object({ sqlitePath: defaultPathSchema }).strict().optional(), contextCompaction: z.object({ + defaultsVersion: z.number().int().positive().max(KUN_CONTEXT_COMPACTION_DEFAULTS_VERSION).optional(), defaultSoftThreshold: z.number().int().positive().optional(), defaultHardThreshold: z.number().int().positive().optional(), summaryMode: kunCompactionSummaryModeSchema.optional(), @@ -440,6 +449,7 @@ const logPatchSchema = z.object({ }).strict() const checkpointCleanupPatchSchema = z.object({ + createEnabled: z.boolean().optional(), enabled: z.boolean().optional(), intervalDays: z.union([ z.literal(1), @@ -1300,6 +1310,7 @@ function stripLegacySettingsPatchKeys(payload: unknown): unknown { const settingsPatchObjectSchema = z.object({ version: z.literal(1).optional(), + initialSetupCompleted: z.boolean().optional(), locale: localeSchema.optional(), theme: themeSchema.optional(), uiFontScale: uiFontScaleSchema.optional(), diff --git a/src/main/ipc/app-ipc-schemas/workspace.ts b/src/main/ipc/app-ipc-schemas/workspace.ts index b282685a9..9fa9a5395 100644 --- a/src/main/ipc/app-ipc-schemas/workspace.ts +++ b/src/main/ipc/app-ipc-schemas/workspace.ts @@ -62,6 +62,13 @@ export const localPdfTextTargetPayloadSchema = z path: rootPathSchema }) .strict() +export const localOfficeDocumentTargetPayloadSchema = z + .object({ + path: rootPathSchema.refine(isAbsolutePath, { + message: 'Office document path must be absolute' + }) + }) + .strict() export const deepseekConfigContentSchema = z.string().max(MAX_CONFIG_FILE_BYTES) export const workspaceRootSchema = trimmedString(MAX_PATH_LENGTH) @@ -194,7 +201,22 @@ export const workspaceFileWritePayloadSchema = z .object({ path: trimmedString(MAX_PATH_LENGTH), workspaceRoot: optionalTrimmedString(MAX_PATH_LENGTH), - content: z.string().max(MAX_BODY_BYTES) + content: z.string().max(MAX_BODY_BYTES), + expectedMtimeMs: z.number().finite().nonnegative().optional(), + force: z.boolean().optional() + }) + .strict() + +export const workspacePreviewLeaseTargetPayloadSchema = z + .object({ + path: trimmedString(MAX_PATH_LENGTH), + workspaceRoot: trimmedString(MAX_PATH_LENGTH) + }) + .strict() + +export const workspacePreviewLeaseReleasePayloadSchema = z + .object({ + leaseId: z.string().trim().regex(/^[A-Za-z0-9_-]{32,128}$/) }) .strict() diff --git a/src/main/ipc/register-app-ipc-handlers.test.ts b/src/main/ipc/register-app-ipc-handlers.test.ts index c35e96365..5d01a8e16 100644 --- a/src/main/ipc/register-app-ipc-handlers.test.ts +++ b/src/main/ipc/register-app-ipc-handlers.test.ts @@ -97,7 +97,7 @@ function settings(): AppSettingsV1 { workspaceRoot: '/tmp/workspace', conversationWorkspaceRoot: '~/Documents/Kun', log: { enabled: false, retentionDays: 7 }, - checkpointCleanup: { enabled: false, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, notifications: { turnComplete: true }, appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, keyboardShortcuts: defaultKeyboardShortcuts(), @@ -121,6 +121,11 @@ function registerOptions(overrides: Partial null, applySettingsPatch, saveSettingsPatch, + resetUnreadableCredentials: vi.fn(async () => ({ + reset: true as const, + backupPath: '/tmp/credential-recovery', + movedItems: ['secret.key'] + })), runtimeRequest: vi.fn() as never, getRuntimeSettingsSyncStatus: () => ({ state: 'idle' as const, @@ -143,6 +148,10 @@ function registerOptions(overrides: Partial '/tmp/logs', logError: vi.fn(), + workspacePreviewProtocols: { + createLease: vi.fn(async () => ({ ok: false, message: 'unavailable' })), + release: vi.fn(() => ({ ok: true })) + } as never, ...overrides } } @@ -170,6 +179,8 @@ describe('registerAppIpcHandlers', () => { registerAppIpcHandlers(registerOptions()) expect(handlers.get('cursor-subscription:discover')).toBeTypeOf('function') + expect(handlers.get('gemini-cli-subscription:status')).toBeTypeOf('function') + expect(handlers.get('gemini-cli-subscription:models')).toBeTypeOf('function') }) it('bypasses cache for development reload commands and keeps packaged reloads ordinary', async () => { @@ -275,6 +286,35 @@ describe('registerAppIpcHandlers', () => { expect(applySettingsPatch).not.toHaveBeenCalled() }) + it('requires trusted native confirmation before resetting unreadable credentials', async () => { + const mainFrame = { processId: 10, routingId: 20 } + const contents = { id: 7, mainFrame } + const mainWindow = { isDestroyed: () => false, webContents: contents } + const resetUnreadableCredentials = vi.fn(async () => ({ + reset: true as const, + backupPath: '/tmp/credential-recovery', + movedItems: ['secret.key'] + })) + registerAppIpcHandlers(registerOptions({ + getMainWindow: () => mainWindow as never, + resetUnreadableCredentials + })) + const handler = handlers.get('credentials:reset-unreadable') + + await expect(handler?.({ + sender: { id: 99 }, + senderFrame: { processId: 90, routingId: 91 } + })).rejects.toThrow(/trusted workbench frame/) + + electronMock.showMessageBox.mockResolvedValueOnce({ response: 1 }) + await expect(handler?.({ sender: contents, senderFrame: mainFrame })).resolves.toEqual({ reset: false }) + expect(resetUnreadableCredentials).not.toHaveBeenCalled() + + electronMock.showMessageBox.mockResolvedValueOnce({ response: 0 }) + await expect(handler?.({ sender: contents, senderFrame: mainFrame })).resolves.toMatchObject({ reset: true }) + expect(resetUnreadableCredentials).toHaveBeenCalledOnce() + }) + it('reports whether a workspace directory currently exists', async () => { const root = mkdtempSync(join(tmpdir(), 'kun-workspace-exists-')) const filePath = join(root, 'not-a-directory') diff --git a/src/main/ipc/register-app-ipc-handlers.ts b/src/main/ipc/register-app-ipc-handlers.ts index 7eb2b45d0..3303932cb 100644 --- a/src/main/ipc/register-app-ipc-handlers.ts +++ b/src/main/ipc/register-app-ipc-handlers.ts @@ -31,6 +31,7 @@ import { import type { ClawImInstallPollResult, ClawImInstallQrResult, + CredentialRecoveryResetResult, ConversationWorkspaceCreateResult, DesktopCommand, KunRuntimeSettingsSyncStatusPayload, @@ -61,6 +62,7 @@ import { gitWorktreeRemoveSchema, guiUpdateChannelSchema, localPdfTextTargetPayloadSchema, + localOfficeDocumentTargetPayloadSchema, logErrorPayloadSchema, notificationPayloadSchema, openEditorPathPayloadSchema, @@ -107,6 +109,8 @@ import { workspaceFileTargetPayloadSchema, workspaceFileWatchPayloadSchema, workspaceFileWritePayloadSchema, + workspacePreviewLeaseReleasePayloadSchema, + workspacePreviewLeaseTargetPayloadSchema, localWhisperDownloadPayloadSchema, localWhisperModelIdPayloadSchema, localWhisperSourceStatusPayloadSchema, @@ -140,7 +144,11 @@ import { } from '../../shared/app-settings' import { detectLegacySessions, importLegacySessions } from '../services/legacy-session-import-service' import { lintProjectDesignMd } from '../services/project-design-md-lint' -import { claudeSubscriptionStatus, runClaudeSetupToken } from '../claude-subscription-auth' +import { + claudeSubscriptionStatus, + probeClaudeSubscription, + runClaudeSubscriptionLogin +} from '../claude-subscription-auth' import { fetchSdkModels } from '../claude-subscription-models' import { agentSdkDownloadState, @@ -168,6 +176,10 @@ import { startAntigravityCliInstall } from '../antigravity-cli' import { discoverCursorSubscription } from '../cursor-subscription-models' +import { + geminiCliSubscriptionModels, + geminiCliSubscriptionStatus +} from '../gemini-cli-subscription' import type { WorkflowRuntime } from '../workflow-runtime' import { checkWorkflowCode } from '../workflow-runtime' import { @@ -262,6 +274,9 @@ import { exportConversation } from '../services/conversation-export-service' import { exportMemoryMarkdown } from '../services/memory-export-service' import { importGithubSkillsToRoot } from '../services/github-skill-import-service' import { readLocalPdfText } from '../services/write-pdf-text-service' +import { readLocalOfficeDocument } from '../services/office-document-service' +import type { WorkspacePreviewProtocolRegistry } from '../services/workspace-preview-protocol' +import { resolveOfficeCliBinary } from '../officecli-resources' import { ensurePptMaster } from '../services/ppt-master-service' import { saveGuiSkillPackage } from '../services/skill-save-service' import { @@ -313,6 +328,7 @@ type RegisterAppIpcHandlersOptions = { getMainWindow: () => BrowserWindow | null applySettingsPatch: (partial: AppSettingsPatch) => Promise saveSettingsPatch: (partial: AppSettingsPatch) => Promise + resetUnreadableCredentials: () => Promise runtimeRequest: ( path: string, method?: string, @@ -340,6 +356,7 @@ type RegisterAppIpcHandlersOptions = { loadGuiUpdaterModule: () => Promise resolveLogDirectory: () => string logError: (category: string, message: string, detail?: unknown) => void + workspacePreviewProtocols: WorkspacePreviewProtocolRegistry } function parseIpcPayload(channel: string, schema: z.ZodType, payload: unknown): T { @@ -551,6 +568,7 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions): getMainWindow, applySettingsPatch, saveSettingsPatch, + resetUnreadableCredentials, runtimeRequest, getRuntimeSettingsSyncStatus, restartRuntime, @@ -764,9 +782,30 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions): } ipcMain.handle('settings:get', async () => store.load()) - // Claude Pro/Max subscription login (compliant path: official CLI does the - // OAuth; we only detect it / capture the setup-token). - ipcMain.handle('claude-subscription:status', async () => claudeSubscriptionStatus()) + ipcMain.handle('credentials:reset-unreadable', async (event): Promise => { + assertTrustedWorkbenchSender(event, getMainWindow) + const parent = getMainWindow() + if (!parent || parent.isDestroyed()) { + throw new Error('Credential recovery window is unavailable.') + } + const confirmation = await dialog.showMessageBox(parent, { + type: 'warning', + title: 'Reset encrypted credentials', + message: 'Reset the credentials that Windows can no longer decrypt?', + detail: [ + 'Kun will back up the unreadable encrypted data before resetting it.', + 'Saved API keys and OAuth sessions must be entered or authorized again.', + 'Conversations, workspaces, and ordinary settings are not removed.' + ].join('\n'), + buttons: ['Back up and reset', 'Cancel'], + defaultId: 1, + cancelId: 1, + noLink: true, + normalizeAccessKeys: true + }) + if (confirmation.response !== 0) return { reset: false } + return resetUnreadableCredentials() + }) // The Claude Code binary (~222MB) is NOT bundled — it's downloaded on demand // into userData/agent-sdk and resolved from there (or kun/node_modules in dev). const claudeSubKunDirs = (): string[] => @@ -776,6 +815,11 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions): ].map((root) => join(root, 'kun')) const claudeSubBinary = (): string | undefined => resolveClaudeBinary(app.getPath('userData'), claudeSubKunDirs()) + // Claude Pro/Max subscription login. The official CLI owns browser OAuth and + // platform credential storage; Kun observes only structured, redacted state. + ipcMain.handle('claude-subscription:status', async () => + claudeSubscriptionStatus({ binaryPath: claudeSubBinary() }) + ) ipcMain.handle('claude-subscription:sdk-status', async () => ({ ...agentSdkStatus(app.getPath('userData'), claudeSubKunDirs()), download: agentSdkDownloadState() @@ -787,7 +831,13 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions): ) ) ipcMain.handle('claude-subscription:login', async () => - runClaudeSetupToken({ binaryPath: claudeSubBinary() }) + runClaudeSubscriptionLogin({ binaryPath: claudeSubBinary() }) + ) + ipcMain.handle('claude-subscription:probe', async (_event, token: unknown) => + probeClaudeSubscription({ + token: typeof token === 'string' ? token : undefined, + binaryPath: claudeSubBinary() + }) ) ipcMain.handle('claude-subscription:models', async (_event, token: unknown) => fetchSdkModels({ @@ -816,6 +866,12 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions): } return fetchAntigravityModels({ binaryPath }) }) + ipcMain.handle('gemini-cli-subscription:status', async () => + geminiCliSubscriptionStatus() + ) + ipcMain.handle('gemini-cli-subscription:models', async () => + geminiCliSubscriptionModels() + ) ipcMain.handle('cursor-subscription:discover', async (_event, payload: unknown) => { const { apiKey } = parseIpcPayload( 'cursor-subscription:discover', @@ -1759,6 +1815,13 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions): ipcMain.handle('git:checkpoint:create', async (_, payload: unknown) => { const request = parseIpcPayload('git:checkpoint:create', gitCheckpointCreatePayloadSchema, payload) const settings = await store.load() + if (!settings.checkpointCleanup.createEnabled) { + return { + ok: false as const, + reason: 'disabled' as const, + message: 'Git checkpoint creation is disabled in settings.' + } + } return createGitCheckpoint({ dataDir: await resolveKunThreadsDataDir(), workspaceRoot: request.workspaceRoot, @@ -1895,6 +1958,15 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions): parseIpcPayload('file:resolve-workspace', workspaceFileTargetPayloadSchema, payload) ) ) + ipcMain.handle('file:open-workspace-system', async (event, payload: unknown) => { + assertTrustedWorkbenchSender(event, options.getMainWindow) + const resolved = await resolveWorkspaceFile( + parseIpcPayload('file:open-workspace-system', workspaceFileTargetPayloadSchema, payload) + ) + if (!resolved.ok) return resolved + const message = await shell.openPath(resolved.path) + return message ? { ok: false as const, message } : { ok: true as const } + }) ipcMain.handle('file:list-workspace-directory', async (_, payload: unknown) => listWorkspaceDirectory( parseIpcPayload('file:list-workspace-directory', workspaceDirectoryTargetPayloadSchema, payload) @@ -1915,6 +1987,26 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions): parseIpcPayload('file:read-workspace-pdf', workspaceFileTargetPayloadSchema, payload) ) ) + ipcMain.handle('file:open-workspace-preview', async (event, payload: unknown) => { + assertTrustedWorkbenchSender(event, options.getMainWindow) + return options.workspacePreviewProtocols.createLease( + event.sender, + parseIpcPayload( + 'file:open-workspace-preview', + workspacePreviewLeaseTargetPayloadSchema, + payload + ) + ) + }) + ipcMain.handle('file:release-workspace-preview', async (event, payload: unknown) => { + assertTrustedWorkbenchSender(event, options.getMainWindow) + const request = parseIpcPayload( + 'file:release-workspace-preview', + workspacePreviewLeaseReleasePayloadSchema, + payload + ) + return options.workspacePreviewProtocols.release(event.sender.id, request.leaseId) + }) ipcMain.handle('file:read-local-pdf-text', async (_, payload: unknown) => { const result = await readLocalPdfText( parseIpcPayload('file:read-local-pdf-text', localPdfTextTargetPayloadSchema, payload) @@ -1933,6 +2025,38 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions): truncated: result.truncated } }) + ipcMain.handle('file:read-local-office-document', async (event, payload: unknown) => { + assertTrustedWorkbenchSender(event, getMainWindow) + const target = parseIpcPayload( + 'file:read-local-office-document', + localOfficeDocumentTargetPayloadSchema, + payload + ) + const binaryPath = resolveOfficeCliBinary({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + appRoot: app.getAppPath(), + explicitPath: process.env.KUN_OFFICECLI_BINARY + }) + if (!binaryPath) { + return { + ok: false as const, + code: 'officecli_unavailable', + message: 'Office document support is unavailable because the bundled OfficeCLI binary was not found.' + } + } + const abortController = new AbortController() + const cancelWhenRendererCloses = (): void => abortController.abort() + event.sender.once('destroyed', cancelWhenRendererCloses) + try { + return await readLocalOfficeDocument(target, { + binaryPath, + signal: abortController.signal + }) + } finally { + event.sender.removeListener('destroyed', cancelWhenRendererCloses) + } + }) ipcMain.handle('file:save-as', async (_, payload: unknown) => saveWorkspaceFileAs(payload, getMainWindow) ) diff --git a/src/main/kun-process.test.ts b/src/main/kun-process.test.ts index 7c5061bdc..4cacfbddd 100644 --- a/src/main/kun-process.test.ts +++ b/src/main/kun-process.test.ts @@ -15,10 +15,13 @@ import { defaultModelProviderSettings, defaultScheduleSettings, defaultWorkflowSettings, + getModelProviderPreset, + modelProviderPresetProfile, resolveKunRuntimeSettings, defaultWriteSettings, defaultTerminalSettings, - type AppSettingsV1 + type AppSettingsV1, + type ModelProviderModelProfileV1 } from '../shared/app-settings' import { KunConfigSchema } from '../../kun/src/config/kun-config.js' @@ -51,7 +54,7 @@ function createSettings(binaryPath: string): AppSettingsV1 { workspaceRoot: '/tmp/workspace', conversationWorkspaceRoot: '~/Documents/Kun', log: { enabled: false, retentionDays: 7 }, - checkpointCleanup: { enabled: false, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, notifications: { turnComplete: true }, appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, keyboardShortcuts: defaultKeyboardShortcuts(), @@ -518,6 +521,101 @@ describe('parseListeningPidsFromNetstat', () => { }) describe('syncGuiManagedKunConfig', () => { + it('exports provider model profiles even when the runtime snapshot is stale', async () => { + if (!tempRoot) throw new Error('temp root not initialized') + const configPath = join(tempRoot, 'config.json') + const module = await import('./kun-process') + const settings = createSettings('/tmp/fake-kun-child.js') + const preset = getModelProviderPreset('gemini-cli-subscription') + if (!preset) throw new Error('Gemini CLI subscription preset is missing') + const geminiProvider = modelProviderPresetProfile(preset, '') + settings.provider.providers.push(geminiProvider) + settings.agents.kun = { + ...settings.agents.kun, + providerId: geminiProvider.id, + model: 'gemini-2.5-flash', + modelProfiles: {} + } + + await module.syncGuiManagedKunConfig(tempRoot, settings.agents.kun, { + scheduleMcp: { + settings, + launch: { + appPath: '/tmp/deepseek-gui-test-app', + execPath: '/tmp/electron', + isPackaged: false + } + } + }) + + const parsed = JSON.parse(readFileSync(configPath, 'utf8')) as any + expect(parsed.models.profiles['gemini-2.5-flash']).toMatchObject({ + contextWindowTokens: 1_048_576 + }) + }) + + it('keeps same-id model profiles scoped to their provider in runtime config', async () => { + if (!tempRoot) throw new Error('temp root not initialized') + const configPath = join(tempRoot, 'config.json') + const module = await import('./kun-process') + const settings = createSettings('/tmp/fake-kun-child.js') + const profile = ( + endpointFormat: 'messages' | 'responses', + contextWindowTokens: number + ): ModelProviderModelProfileV1 => ({ + contextWindowTokens, + inputModalities: ['text'], + outputModalities: ['text'], + supportsToolCalling: true, + messageParts: ['text'], + endpointFormat + }) + settings.provider.providers.push( + { + id: 'shared-a', + name: 'Shared A', + apiKey: 'sk-a', + baseUrl: 'https://a.example/v1', + endpointFormat: 'chat_completions', + models: ['shared-model'], + modelProfiles: { 'shared-model': profile('messages', 128_000) } + }, + { + id: 'shared-b', + name: 'Shared B', + apiKey: 'sk-b', + baseUrl: 'https://b.example/v1', + endpointFormat: 'chat_completions', + models: ['shared-model'], + modelProfiles: { 'shared-model': profile('responses', 256_000) } + } + ) + settings.agents.kun = { + ...settings.agents.kun, + providerId: 'shared-b', + model: 'shared-model' + } + + await module.syncGuiManagedKunConfig(tempRoot, resolveKunRuntimeSettings(settings), { + appSettings: settings + }) + + const parsed = JSON.parse(readFileSync(configPath, 'utf8')) as any + expect(KunConfigSchema.safeParse(parsed).success).toBe(true) + expect(parsed.models.profiles['shared-model']).toMatchObject({ + endpointFormat: 'responses', + contextWindowTokens: 256_000 + }) + expect(parsed.serve.providers['shared-a'].modelProfiles['shared-model']).toMatchObject({ + endpointFormat: 'messages', + contextWindowTokens: 128_000 + }) + expect(parsed.serve.providers['shared-b'].modelProfiles['shared-model']).toMatchObject({ + endpointFormat: 'responses', + contextWindowTokens: 256_000 + }) + }) + it('creates GUI-managed config with attachments enabled for image paste/upload', async () => { if (!tempRoot) throw new Error('temp root not initialized') const configPath = join(tempRoot, 'config.json') @@ -818,7 +916,8 @@ describe('syncGuiManagedKunConfig', () => { 'OpenAI-Beta': 'responses=experimental' } }) - expect(parsed.capabilities.imageGen.headers['User-Agent']).toContain('codex_cli_rs') + expect(parsed.capabilities.imageGen.headers['User-Agent']).toMatch(/^codex_cli_rs\/0\.145\.0 \(.+; .+\)$/) + expect(parsed.capabilities.imageGen.headers['User-Agent']).not.toMatch(/deepseekgui|kun/i) expect(typeof parsed.capabilities.imageGen.headers.session_id).toBe('string') expect(KunConfigSchema.safeParse(parsed).success).toBe(true) }) @@ -864,7 +963,7 @@ describe('syncGuiManagedKunConfig', () => { expect(capability.apiKey).toBe('grok-access-token') expect(capability.headers).toMatchObject({ 'x-grok-client-version': expect.any(String), - 'x-grok-client-identifier': 'kun' + 'x-grok-client-identifier': 'grok-shell' }) expect(capability.headers['X-XAI-Token-Auth']).toBeUndefined() expect(capability.headers['x-authenticateresponse']).toBeUndefined() diff --git a/src/main/kun-process.ts b/src/main/kun-process.ts index b70e9b6a6..1458bd4f9 100644 --- a/src/main/kun-process.ts +++ b/src/main/kun-process.ts @@ -91,6 +91,7 @@ import { skillCapabilityConfigForRuntime } from './runtime/kun-runtime-mcp-config' import { availableBundledExtensionsDirectory } from './bundled-extension-resources' +import { resolveOfficeCliBinary } from './officecli-resources' import { subagentProfilesForRuntime } from './runtime/kun-runtime-subagent-config' import { syncGuiManagedKunConfig } from './runtime/kun-runtime-config-service' @@ -344,13 +345,20 @@ async function startKunChildOnce( // resolvable from kun/node_modules — the SDK auto-resolves it there. const claudeBinary = resolveClaudeBinary(app.getPath('userData'), [join(appRoot(), 'kun')]) const antigravityBinary = resolveAntigravityCliBinary(app.getPath('userData')) + const officeCliBinary = resolveOfficeCliBinary({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + appRoot: root, + explicitPath: process.env.KUN_OFFICECLI_BINARY + }) const childEnv: NodeJS.ProcessEnv = { ...process.env, KUN_RUNTIME_TOKEN: runtime.runtimeToken, DEEPSEEK_API_KEY: defaultClientApiKey || process.env.DEEPSEEK_API_KEY || '', ...(activeProviderKind ? { KUN_RUNTIME_PROVIDER_KIND: activeProviderKind } : {}), ...(claudeBinary ? { KUN_CLAUDE_BINARY: claudeBinary } : {}), - ...(antigravityBinary ? { KUN_ANTIGRAVITY_BINARY: antigravityBinary } : {}) + ...(antigravityBinary ? { KUN_ANTIGRAVITY_BINARY: antigravityBinary } : {}), + ...(officeCliBinary ? { KUN_OFFICECLI_BINARY: officeCliBinary } : {}) } const bundledExtensionsDirectory = availableBundledExtensionsDirectory({ isPackaged: app.isPackaged, diff --git a/src/main/kun-regression.test.ts b/src/main/kun-regression.test.ts index fce34a9b5..efee427b4 100644 --- a/src/main/kun-regression.test.ts +++ b/src/main/kun-regression.test.ts @@ -122,7 +122,7 @@ describe('Kun single-agent regression', () => { workspaceRoot: '/tmp', conversationWorkspaceRoot: '~/Documents/Kun', log: { enabled: true, retentionDays: 7 }, - checkpointCleanup: { enabled: false, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, notifications: { turnComplete: true }, appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, keyboardShortcuts: defaultKeyboardShortcuts(), diff --git a/src/main/legacy-provider-settings-migration.test.ts b/src/main/legacy-provider-settings-migration.test.ts index 06248900e..2f68dc0db 100644 --- a/src/main/legacy-provider-settings-migration.test.ts +++ b/src/main/legacy-provider-settings-migration.test.ts @@ -21,6 +21,21 @@ import { syncGuiManagedKunConfig } from './runtime/kun-runtime-config-service' import { JsonSettingsStore } from './settings-store' describe('LegacyProviderSettingsMigrationCoordinator', () => { + it('does not cache a failed credential runtime initialization', async () => { + const runtimeFactory = vi.fn() + .mockRejectedValueOnce(new Error('temporary DPAPI failure')) + .mockRejectedValueOnce(new Error('second initialization reached')) + const coordinator = new LegacyProviderSettingsMigrationCoordinator(runtimeFactory) + const input = { + provider: defaultModelProviderSettings(), + agents: { kun: { ...defaultKunRuntimeSettings(), dataDir: '/tmp/kun-credential-retry' } } + } as AppSettingsV1 + + await expect(coordinator.prepare(input)).rejects.toThrow('temporary DPAPI failure') + await expect(coordinator.prepare(input)).rejects.toThrow('second initialization reached') + expect(runtimeFactory).toHaveBeenCalledTimes(2) + }) + it('emits distinct protected credential bindings for numbered plan accounts', () => { const providerSettings = defaultModelProviderSettings() const kimi = getModelProviderPreset('kimi-code')! @@ -64,6 +79,48 @@ describe('LegacyProviderSettingsMigrationCoordinator', () => { expect(JSON.stringify(runtimeProviders)).not.toContain('cursor-secret') }) + it('projects legacy subscription profiles through their preset SDK transports', () => { + const providerSettings = defaultModelProviderSettings() + const legacySubscriptions = [ + 'claude-subscription', + 'cursor-subscription', + 'gemini-subscription', + 'gemini-cli-subscription' + ].map((providerId) => { + const { kind: _removedKind, ...profile } = modelProviderPresetProfile( + getModelProviderPreset(providerId)!, + '' + ) + return profile + }) + const runtimeProviders = providersConfigForRuntime({ + provider: { + ...providerSettings, + providers: [...providerSettings.providers, ...legacySubscriptions] + } + } as AppSettingsV1) + + expect(runtimeProviders['claude-subscription']).toEqual(expect.objectContaining({ + kind: 'agent-sdk', + credentialSourceId: 'settings:provider:claude-subscription' + })) + expect(runtimeProviders['cursor-subscription']).toEqual(expect.objectContaining({ + kind: 'cursor-sdk', + credentialSourceId: 'settings:provider:cursor-subscription' + })) + expect(runtimeProviders['gemini-subscription']).toEqual(expect.objectContaining({ + kind: 'antigravity-cli', + credentialSourceId: 'settings:provider:gemini-subscription' + })) + expect(runtimeProviders['gemini-cli-subscription']).toEqual(expect.objectContaining({ + kind: 'gemini-cli-api', + credentialSourceId: 'settings:provider:gemini-cli-subscription' + })) + expect(runtimeProviders['cursor-subscription']?.baseUrl).toBeUndefined() + expect(runtimeProviders['gemini-subscription']?.baseUrl).toBeUndefined() + expect(runtimeProviders['gemini-cli-subscription']?.baseUrl).toBeUndefined() + }) + it('backs up and removes plaintext while keeping secure bindings readable across restarts', async () => { const userDataDir = await mkdtemp(join(tmpdir(), 'kun-settings-credential-migration-')) const dataDir = join(userDataDir, 'runtime-data') diff --git a/src/main/legacy-provider-settings-migration.ts b/src/main/legacy-provider-settings-migration.ts index ac0bcdf29..8a8e24d3e 100644 --- a/src/main/legacy-provider-settings-migration.ts +++ b/src/main/legacy-provider-settings-migration.ts @@ -34,6 +34,8 @@ type MigrationRuntime = { service: LegacyProviderCredentialMigrationService } +type MigrationRuntimeFactory = (dataDir: string) => Promise + /** * Bridges the GUI's legacy settings shape to Kun's protected account store. * It intentionally leaves existing synchronous settings consumers on an @@ -43,6 +45,8 @@ type MigrationRuntime = { export class LegacyProviderSettingsMigrationCoordinator { private readonly runtimes = new Map>() + constructor(private readonly runtimeFactory: MigrationRuntimeFactory = createMigrationRuntime) {} + async prepare( settings: AppSettingsV1, options: { replaceCommitted?: boolean } = {} @@ -85,11 +89,18 @@ export class LegacyProviderSettingsMigrationCoordinator { private runtime(dataDir: string): Promise { let pending = this.runtimes.get(dataDir) if (!pending) { - pending = createMigrationRuntime(dataDir) + pending = this.runtimeFactory(dataDir) this.runtimes.set(dataDir, pending) + void pending.catch(() => { + if (this.runtimes.get(dataDir) === pending) this.runtimes.delete(dataDir) + }) } return pending } + + invalidateRuntime(dataDir: string): void { + this.runtimes.delete(dataDir) + } } export function legacyProviderCredentialSourceId(providerId: string): string { @@ -225,7 +236,7 @@ function isRecognizedSettingsSource(sourceId: string): boolean { return sourceId === LEGACY_RUNTIME_OVERRIDE_SOURCE_ID || sourceId.startsWith(LEGACY_PROVIDER_SOURCE_PREFIX) } -function resolveSettingsDataDir(settings: AppSettingsV1): string { +export function resolveSettingsDataDir(settings: AppSettingsV1): string { const value = getKunRuntimeSettings(settings).dataDir.trim() if (value === '~') return homedir() if (value.startsWith('~/') || value.startsWith('~\\')) { diff --git a/src/main/managed-runtime-startup-policy.test.ts b/src/main/managed-runtime-startup-policy.test.ts new file mode 100644 index 000000000..a75333146 --- /dev/null +++ b/src/main/managed-runtime-startup-policy.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import type { AppSettingsV1 } from '../shared/app-settings' +import { managedKunHostCanAutoStart } from './managed-runtime-startup-policy' + +describe('managed Kun host startup policy', () => { + it('allows an SDK-only configuration without a default DeepSeek key', () => { + const settings = { + agents: { + kun: { + autoStart: true, + apiKey: '', + providerId: 'cursor-subscription', + model: 'auto' + } + } + } as AppSettingsV1 + + expect(managedKunHostCanAutoStart(settings)).toBe(true) + }) + + it('still respects an explicit auto-start disable', () => { + const settings = { + agents: { kun: { autoStart: false, apiKey: '' } } + } as AppSettingsV1 + + expect(managedKunHostCanAutoStart(settings)).toBe(false) + }) +}) diff --git a/src/main/managed-runtime-startup-policy.ts b/src/main/managed-runtime-startup-policy.ts new file mode 100644 index 000000000..aafebb220 --- /dev/null +++ b/src/main/managed-runtime-startup-policy.ts @@ -0,0 +1,14 @@ +import { + getKunRuntimeSettings, + type AppSettingsV1 +} from '../shared/app-settings' + +/** + * The managed process is the local Kun host, not one specific upstream model. + * Provider-specific, protected, SDK, and ambient credentials are resolved + * after the host starts, so an unrelated default DeepSeek key is not a launch + * prerequisite. + */ +export function managedKunHostCanAutoStart(settings: AppSettingsV1): boolean { + return getKunRuntimeSettings(settings).autoStart +} diff --git a/src/main/models-dev-catalog.test.ts b/src/main/models-dev-catalog.test.ts index ebec75f48..5a0f3884c 100644 --- a/src/main/models-dev-catalog.test.ts +++ b/src/main/models-dev-catalog.test.ts @@ -152,6 +152,7 @@ describe('resolveModelsDevProvider', () => { ['codex', 'https://chatgpt.com/backend-api/codex/responses', 'openai', 'enrichment-only'], ['claude-subscription', 'https://api.anthropic.com', 'anthropic', 'enrichment-only'], ['gemini-subscription', '', 'google', 'enrichment-only'], + ['gemini-cli-subscription', '', 'google', 'enrichment-only'], ['grok-subscription', 'https://cli-chat-proxy.grok.com/v1', 'xai', 'enrichment-only'], ['vercel-ai-gateway', 'https://ai-gateway.vercel.sh/v1', 'vercel', 'catalog'] ])('maps %s deterministically', (providerId, baseUrl, providerKey, matchMode) => { diff --git a/src/main/models-dev-catalog.ts b/src/main/models-dev-catalog.ts index d11648e2d..5142f3692 100644 --- a/src/main/models-dev-catalog.ts +++ b/src/main/models-dev-catalog.ts @@ -68,6 +68,7 @@ const PROFILE_MATCHES: Record = { codex: catalogMatch('openai', 'enrichment-only'), 'claude-subscription': catalogMatch('anthropic', 'enrichment-only'), 'gemini-subscription': catalogMatch('google', 'enrichment-only'), + 'gemini-cli-subscription': catalogMatch('google', 'enrichment-only'), 'grok-subscription': catalogMatch('xai', 'enrichment-only'), 'vercel-ai-gateway': catalogMatch('vercel') } diff --git a/src/main/officecli-resources.test.ts b/src/main/officecli-resources.test.ts new file mode 100644 index 000000000..35a1e8317 --- /dev/null +++ b/src/main/officecli-resources.test.ts @@ -0,0 +1,80 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { resolveOfficeCliBinary } from './officecli-resources' + +const roots: string[] = [] +const sha256 = 'a'.repeat(64) + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'kun-officecli-resources-')) + roots.push(root) + return root +} + +function touch(path: string): void { + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, '') +} + +afterEach(() => { + while (roots.length > 0) { + const root = roots.pop() + if (root) rmSync(root, { recursive: true, force: true }) + } +}) + +describe('OfficeCLI resource resolution', () => { + it('prefers an explicit existing binary', () => { + const root = tempRoot() + const explicitPath = join(root, 'custom-officecli') + touch(explicitPath) + + expect(resolveOfficeCliBinary({ + isPackaged: false, + resourcesPath: join(root, 'packaged-resources'), + appRoot: root, + platform: 'linux', + arch: 'x64', + explicitPath + })).toBe(resolve(explicitPath)) + }) + + it('uses the prepared development binary only for its selected target', () => { + const root = tempRoot() + const currentRoot = join(root, 'resources', 'officecli', 'current') + const binaryPath = join(currentRoot, 'officecli') + touch(binaryPath) + writeFileSync(join(currentRoot, 'selected.json'), JSON.stringify({ + version: '1.0.141', + platform: 'linux', + arch: 'x64', + sha256 + })) + + const input = { + isPackaged: false, + resourcesPath: join(root, 'packaged-resources'), + appRoot: root, + platform: 'linux' as const + } + expect(resolveOfficeCliBinary({ ...input, arch: 'x64' })).toBe(binaryPath) + expect(resolveOfficeCliBinary({ ...input, arch: 'arm64' })).toBeUndefined() + }) + + it('resolves the packaged platform executable without development metadata', () => { + const root = tempRoot() + const resourcesPath = join(root, 'resources') + const binaryPath = join(resourcesPath, 'officecli', 'officecli.exe') + touch(binaryPath) + + expect(resolveOfficeCliBinary({ + isPackaged: true, + resourcesPath, + appRoot: root, + platform: 'win32', + arch: 'x64' + })).toBe(binaryPath) + }) +}) diff --git a/src/main/officecli-resources.ts b/src/main/officecli-resources.ts new file mode 100644 index 000000000..ed3d2f6fe --- /dev/null +++ b/src/main/officecli-resources.ts @@ -0,0 +1,58 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' + +export const OFFICECLI_RESOURCE_DIRECTORY = 'officecli' + +export function officeCliExecutableName(platform: NodeJS.Platform = process.platform): string { + return platform === 'win32' ? 'officecli.exe' : 'officecli' +} + +export function resolveOfficeCliBinary(input: { + isPackaged: boolean + resourcesPath: string + appRoot: string + platform?: NodeJS.Platform + arch?: string + explicitPath?: string +}): string | undefined { + const explicit = input.explicitPath?.trim() + if (explicit && existsSync(explicit)) return resolve(explicit) + + const platform = input.platform ?? process.platform + const arch = input.arch ?? process.arch + const executable = officeCliExecutableName(platform) + const currentRoot = join( + input.appRoot, + 'resources', + OFFICECLI_RESOURCE_DIRECTORY, + 'current' + ) + const candidates = input.isPackaged + ? [join(input.resourcesPath, OFFICECLI_RESOURCE_DIRECTORY, executable)] + : [ + ...(selectedOfficeCliTargetMatches(currentRoot, platform, arch) + ? [join(currentRoot, executable)] + : []), + join(input.appRoot, 'resources', OFFICECLI_RESOURCE_DIRECTORY, executable) + ] + return candidates.find((candidate) => existsSync(candidate)) +} + +function selectedOfficeCliTargetMatches( + currentRoot: string, + platform: NodeJS.Platform, + arch: string +): boolean { + try { + const selected = JSON.parse( + readFileSync(join(currentRoot, 'selected.json'), 'utf8') + ) as { version?: unknown; platform?: unknown; arch?: unknown; sha256?: unknown } + return selected.version === '1.0.141' && + selected.platform === platform && + selected.arch === arch && + typeof selected.sha256 === 'string' && + /^[a-f0-9]{64}$/.test(selected.sha256) + } catch { + return false + } +} diff --git a/src/main/packaging-config.test.ts b/src/main/packaging-config.test.ts index 6a63fc798..276f5b867 100644 --- a/src/main/packaging-config.test.ts +++ b/src/main/packaging-config.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { builtinModules, createRequire } from 'node:module' import { tmpdir } from 'node:os' @@ -10,6 +11,7 @@ const builderConfig = require('../../electron-builder.config.cjs') const afterPack = require('../../scripts/after-pack.cjs') const nativeBuildEnv = require('../../scripts/electron-native-build-env.cjs') const macNotarize = require('../../scripts/mac-notarize.cjs') +const officeCliPrepare = require('../../scripts/prepare-officecli.cjs') const tempRoots: string[] = [] @@ -131,6 +133,20 @@ function createMacPackContext(root: string): { } } +function createWindowsPackContext(root: string, signIf: (path: string) => Promise) { + return { + appOutDir: join(root, 'win-unpacked'), + electronPlatformName: 'win32', + arch: 'x64', + packager: { + appInfo: { + productFilename: 'Kun' + }, + signIf + } + } +} + afterEach(() => { while (tempRoots.length > 0) { const root = tempRoots.pop() @@ -196,11 +212,122 @@ describe('electron-builder Kun packaging', () => { .toContain('Copyright (c) 2025 Addy Osmani') }) + it('bundles one pinned OfficeCLI target with its manifests and legal notices', () => { + expect(builderConfig.extraResources).toEqual(expect.arrayContaining([ + { + from: 'resources/officecli/current', + to: 'officecli', + filter: ['officecli', 'officecli.exe', 'selected.json'] + }, + { + from: 'resources/officecli/manifest.json', + to: 'officecli/manifest.json' + }, + { + from: 'resources/officecli/legal', + to: 'officecli/legal', + filter: ['LICENSE', 'NOTICE', 'THIRD-PARTY-NOTICES.txt'] + } + ])) + const manifest = JSON.parse( + readFileSync(join(process.cwd(), 'resources/officecli/manifest.json'), 'utf8') + ) + expect(manifest).toMatchObject({ + schemaVersion: 1, + version: '1.0.141', + releaseTag: 'v1.0.141', + schemaCrc: '2da9da05' + }) + expect(Object.keys(manifest.assets).sort()).toEqual([ + 'darwin-arm64', + 'darwin-x64', + 'linux-x64', + 'win32-x64' + ]) + for (const asset of Object.values(manifest.assets) as Array>) { + expect(asset.sha256).toMatch(/^[a-f0-9]{64}$/) + expect(asset.size).toEqual(expect.any(Number)) + expect(asset.url).toMatch(/^https:\/\/github\.com\/iOfficeAI\/OfficeCLI\/releases\/download\/v1\.0\.141\//) + } + expect(officeCliPrepare._internals.parseArgs(['--platform', 'mac', '--arch', 'x64'])) + .toEqual({ platform: 'darwin', arch: 'x64' }) + }) + + it('verifies the packaged OfficeCLI architecture selection, digest, mode, and notices', () => { + const root = tempRoot() + const context = createMacPackContext(root) + const officeRoot = join(afterPack._internals.packedResourcesDir(context), 'officecli') + const binary = Buffer.from('pinned officecli fixture') + const digest = createHash('sha256').update(binary).digest('hex') + const manifest = { + schemaVersion: 1, + version: '1.0.141', + releaseTag: 'v1.0.141', + schemaCrc: '2da9da05', + assets: { + 'darwin-arm64': { + name: 'officecli-mac-arm64', + size: binary.length, + sha256: digest, + url: 'https://example.invalid/officecli' + } + } + } + const selected = { + schemaVersion: 1, + version: '1.0.141', + releaseTag: 'v1.0.141', + schemaCrc: '2da9da05', + platform: 'darwin', + arch: 'arm64', + asset: 'officecli-mac-arm64', + size: binary.length, + sha256: digest + } + mkdirSync(join(officeRoot, 'legal'), { recursive: true }) + writeFileSync(join(officeRoot, 'manifest.json'), JSON.stringify(manifest)) + writeFileSync(join(officeRoot, 'selected.json'), JSON.stringify(selected)) + writeFileSync(join(officeRoot, 'officecli'), binary) + chmodSync(join(officeRoot, 'officecli'), 0o644) + for (const name of ['LICENSE', 'NOTICE', 'THIRD-PARTY-NOTICES.txt']) { + writeFileSync(join(officeRoot, 'legal', name), name) + } + + expect(() => afterPack._internals.validateBundledOfficeCli(context)).not.toThrow() + expect(statSync(join(officeRoot, 'officecli')).mode & 0o111).not.toBe(0) + + writeFileSync(join(officeRoot, 'officecli.exe'), 'wrong architecture') + expect(() => afterPack._internals.validateBundledOfficeCli(context)).toThrow( + /exactly one darwin-arm64/ + ) + }) + + it('passes the nested OfficeCLI executable through the Windows signing manager', async () => { + const root = tempRoot() + const signedPaths: string[] = [] + const context = createWindowsPackContext(root, async (path) => { + signedPaths.push(path) + return true + }) + + await expect(afterPack._internals.maybeSignBundledOfficeCli(context)).resolves.toBe(true) + expect(signedPaths).toEqual([ + join(context.appOutDir, 'resources', 'officecli', 'officecli.exe') + ]) + }) + it('validates the unpacked Kun runtime before release artifacts are created', () => { const root = tempRoot() const context = createMacPackContext(root) const unpackedRoot = afterPack._internals.unpackedAppRoot(context) + expect(afterPack.KUN_RUNTIME_REQUIRED_PATHS).toEqual(expect.arrayContaining([ + 'kun/node_modules/typescript/package.json', + 'kun/node_modules/typescript/lib/typescript.js', + 'kun/node_modules/typescript-language-server/package.json', + 'kun/node_modules/typescript-language-server/lib/cli.mjs' + ])) + for (const relativePath of afterPack.KUN_RUNTIME_REQUIRED_PATHS) { touch(join(unpackedRoot, relativePath)) } @@ -258,6 +385,9 @@ describe('electron-builder Kun packaging', () => { const installerScript = readFileSync(join(process.cwd(), 'build/installer.nsh'), 'utf8') expect(builderConfig.nsis.include).toBe('build/installer.nsh') + expect(installerScript).toContain('!macro customInit') + expect(installerScript).toContain('${if} ${isUpdated}') + expect(installerScript).toContain('SetSilent silent') expect(installerScript).toContain('customCheckAppRunning') expect(installerScript).toContain('customUnInstallCheck') expect(installerScript).toContain('customUnInstallCheckCurrentUser') diff --git a/src/main/runtime-settings-apply-mode.test.ts b/src/main/runtime-settings-apply-mode.test.ts index 0d9594c1d..3e0b452c7 100644 --- a/src/main/runtime-settings-apply-mode.test.ts +++ b/src/main/runtime-settings-apply-mode.test.ts @@ -32,7 +32,7 @@ function settings(): AppSettingsV1 { workspaceRoot: '/tmp/workspace', conversationWorkspaceRoot: '~/Documents/Kun', log: { enabled: false, retentionDays: 7 }, - checkpointCleanup: { enabled: false, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, notifications: { turnComplete: true }, appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, keyboardShortcuts: defaultKeyboardShortcuts(), diff --git a/src/main/runtime/kun-adapter.test.ts b/src/main/runtime/kun-adapter.test.ts index 253e28fc2..2428351d9 100644 --- a/src/main/runtime/kun-adapter.test.ts +++ b/src/main/runtime/kun-adapter.test.ts @@ -34,7 +34,7 @@ function settingsForPort(port: number): AppSettingsV1 { workspaceRoot: '/tmp', conversationWorkspaceRoot: '~/Documents/Kun', log: { enabled: true, retentionDays: 7 }, - checkpointCleanup: { enabled: false, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, notifications: { turnComplete: true }, appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, keyboardShortcuts: defaultKeyboardShortcuts(), diff --git a/src/main/runtime/kun-runtime-config-service.ts b/src/main/runtime/kun-runtime-config-service.ts index 1a7c9673c..ba9a9d8d8 100644 --- a/src/main/runtime/kun-runtime-config-service.ts +++ b/src/main/runtime/kun-runtime-config-service.ts @@ -103,6 +103,10 @@ export async function syncGuiManagedKunConfig( ) ) const appSettings = options?.appSettings ?? options?.scheduleMcp?.settings + const modelProfiles = { + ...runtime.modelProfiles, + ...(appSettings ? resolveKunRuntimeSettings(appSettings).modelProfiles : {}) + } const projectMcpServers = appSettings ? await approvedProjectMcpServers(appSettings) : {} @@ -116,8 +120,8 @@ export async function syncGuiManagedKunConfig( objectValue(capabilities.skills), appSettings ) - const providers = options?.scheduleMcp?.settings - ? providersConfigForRuntime(options.scheduleMcp.settings) + const providers = appSettings + ? providersConfigForRuntime(appSettings) : undefined const routePools = appSettings ? routePoolsConfigForRuntime(appSettings) : undefined const localModelGateway = appSettings ? localModelGatewayConfigForRuntime(appSettings) : undefined @@ -147,7 +151,7 @@ export async function syncGuiManagedKunConfig( ...(routePools ? { routePools } : {}), ...(localModelGateway ? { localModelGateway } : {}) }, - models: modelConfigForRuntime(objectValue(existing?.models), runtime.modelProfiles), + models: modelConfigForRuntime(objectValue(existing?.models), modelProfiles), contextCompaction: contextCompactionConfigForRuntime( runtime.contextCompaction, objectValue(existing?.contextCompaction) diff --git a/src/main/runtime/kun-runtime-mcp-config.ts b/src/main/runtime/kun-runtime-mcp-config.ts index 0783d9c6f..58869ab92 100644 --- a/src/main/runtime/kun-runtime-mcp-config.ts +++ b/src/main/runtime/kun-runtime-mcp-config.ts @@ -117,6 +117,7 @@ function normalizeGuiManagedMcpServer(server: unknown): Record if (!transport) return null const workspaceRoots = stringArrayValue(raw.workspaceRoots) const trustedWorkspaceRoots = stringArrayValue(raw.trustedWorkspaceRoots) + const planModeReadOnlyTools = stringArrayValue(raw.planModeReadOnlyTools) const trustScope = normalizeMcpTrustScope(raw.trustScope, trustedWorkspaceRoots) if (trustScope === 'workspace' && trustedWorkspaceRoots.length === 0) return null const timeoutMs = positiveIntegerValue(raw.timeoutMs) @@ -133,6 +134,7 @@ function normalizeGuiManagedMcpServer(server: unknown): Record ...(Object.keys(oauth).length ? { oauth } : {}), trustScope, ...(trustedWorkspaceRoots.length ? { trustedWorkspaceRoots } : {}), + ...(planModeReadOnlyTools.length ? { planModeReadOnlyTools } : {}), ...(timeoutMs ? { timeoutMs } : {}) }) return parsed.success ? objectValue(parsed.data) : null diff --git a/src/main/runtime/kun-runtime-model-config.ts b/src/main/runtime/kun-runtime-model-config.ts index 03cc6f839..4ed4f6a10 100644 --- a/src/main/runtime/kun-runtime-model-config.ts +++ b/src/main/runtime/kun-runtime-model-config.ts @@ -61,11 +61,12 @@ export function providersConfigForRuntime( for (const provider of getModelProviderSettings(settings).providers as ModelProviderProfileV1[]) { const id = provider.id?.trim() const baseUrl = provider.baseUrl?.trim() - const isDelegated = + const isKeylessTransport = provider.kind === 'agent-sdk' || provider.kind === 'antigravity-cli' || + provider.kind === 'gemini-cli-api' || provider.kind === 'cursor-sdk' - if (!id || (!baseUrl && !isDelegated)) continue + if (!id || (!baseUrl && !isKeylessTransport)) continue out[id] = { // Provider secrets live in the protected account store. The runtime // resolves this opaque source binding after reading config.json. @@ -75,6 +76,7 @@ export function providersConfigForRuntime( ...(provider.kind ? { kind: provider.kind } : {}), ...(provider.endpointFormat ? { endpointFormat: provider.endpointFormat } : {}), retry: provider.retry, + modelProfiles: modelConfigProfilesFromProviderProfiles(provider.modelProfiles), ...(proxyUrl ? { modelProxyUrl: proxyUrl } : {}), // Credential-derived transport headers are reconstructed in Kun from // the protected binding and are never persisted in config.json. diff --git a/src/main/runtime/managed-runtime-idle.test.ts b/src/main/runtime/managed-runtime-idle.test.ts index 5781c602c..b334a09bc 100644 --- a/src/main/runtime/managed-runtime-idle.test.ts +++ b/src/main/runtime/managed-runtime-idle.test.ts @@ -28,7 +28,7 @@ const settings: AppSettingsV1 = { workspaceRoot: '/tmp/workspace', conversationWorkspaceRoot: '~/Documents/Kun', log: { enabled: false, retentionDays: 7 }, - checkpointCleanup: { enabled: false, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, notifications: { turnComplete: true }, appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, keyboardShortcuts: defaultKeyboardShortcuts(), diff --git a/src/main/schedule-runtime.test.ts b/src/main/schedule-runtime.test.ts index 26be3cdbb..e4cc03a27 100644 --- a/src/main/schedule-runtime.test.ts +++ b/src/main/schedule-runtime.test.ts @@ -102,7 +102,7 @@ function settingsWith( workspaceRoot: testWorkspaceRoot, conversationWorkspaceRoot: '~/Documents/Kun', log: { enabled: true, retentionDays: 7 }, - checkpointCleanup: { enabled: false, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, notifications: { turnComplete: true }, appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, keyboardShortcuts: defaultKeyboardShortcuts(), diff --git a/src/main/services/gemini-cli-speech-to-text-service.test.ts b/src/main/services/gemini-cli-speech-to-text-service.test.ts new file mode 100644 index 000000000..5586c9827 --- /dev/null +++ b/src/main/services/gemini-cli-speech-to-text-service.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest' +import type { KunSpeechToTextSettingsV1 } from '../../shared/app-settings' +import { + speechTranscriptionPrompt, + transcribeViaGeminiCliAudio +} from './gemini-cli-speech-to-text-service' + +const SPEECH_SETTINGS: KunSpeechToTextSettingsV1 = { + enabled: true, + providerId: 'gemini-cli-subscription', + protocol: 'gemini-cli-audio', + baseUrl: '', + apiKey: '', + model: 'gemini-2.5-flash', + localWhisperDownloadSource: 'huggingface', + language: 'zh', + timeoutMs: 30_000 +} + +describe('Gemini CLI speech-to-text service', () => { + it('sends inline audio through the official Code Assist OAuth path', async () => { + const requests: Array<{ + url: string + authorization: string + headers: Record + body: Record + }> = [] + const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const headers = new Headers(init?.headers) + requests.push({ + url: String(url), + authorization: headers.get('authorization') ?? '', + headers: Object.fromEntries(headers.entries()), + body: JSON.parse(String(init?.body ?? '{}')) as Record + }) + if (String(url).endsWith(':loadCodeAssist')) { + return new Response(JSON.stringify({ + cloudaicompanionProject: 'managed-project' + }), { status: 200 }) + } + return new Response(JSON.stringify({ + response: { + candidates: [{ + content: { + parts: [ + { text: 'hidden', thought: true }, + { text: ' 你好,世界 ' } + ] + } + }] + } + }), { status: 200 }) + }) as unknown as typeof fetch + const accessToken = vi.fn(async () => 'official-access-token') + + const text = await transcribeViaGeminiCliAudio( + SPEECH_SETTINGS, + { + audioBase64: 'ZmFrZS13YXY=', + mimeType: 'audio/wav', + durationMs: 500 + }, + { + fetchImpl, + oauthSource: { accessToken }, + endpoint: 'https://code-assist.example.test', + apiVersion: 'v1internal' + } + ) + + expect(text).toBe('你好,世界') + expect(requests.map((request) => request.url)).toEqual([ + 'https://code-assist.example.test/v1internal:loadCodeAssist', + 'https://code-assist.example.test/v1internal:generateContent' + ]) + expect(requests[1].authorization).toBe('Bearer official-access-token') + expect(requests[1].headers['user-agent']).toBe('google-gemini-cli') + expect(requests[1].headers['x-goog-api-client']).toBe('gl-node/kun gemini-cli-audio') + expect(requests[1].body).toMatchObject({ + model: 'gemini-2.5-flash', + project: 'managed-project', + request: { + contents: [{ + role: 'user', + parts: [ + { text: expect.stringContaining('expected language is zh') }, + { + inlineData: { + mimeType: 'audio/wav', + data: 'ZmFrZS13YXY=' + } + } + ] + }] + } + }) + }) + + it('refreshes a rejected OAuth token once', async () => { + let setupAttempts = 0 + const fetchImpl = vi.fn(async (url: string | URL | Request) => { + if (String(url).endsWith(':loadCodeAssist')) { + setupAttempts += 1 + if (setupAttempts === 1) return new Response('unauthorized', { status: 401 }) + return new Response(JSON.stringify({ + cloudaicompanionProject: 'project' + }), { status: 200 }) + } + return new Response(JSON.stringify({ + response: { + candidates: [{ content: { parts: [{ text: 'recovered' }] } }] + } + }), { status: 200 }) + }) as unknown as typeof fetch + const accessToken = vi.fn(async (rejected?: string) => + rejected ? 'fresh-token' : 'expired-token' + ) + + await expect(transcribeViaGeminiCliAudio( + SPEECH_SETTINGS, + { audioBase64: 'YXVkaW8=', mimeType: 'audio/wav' }, + { + fetchImpl, + oauthSource: { accessToken }, + endpoint: 'https://code-assist.example.test' + } + )).resolves.toBe('recovered') + + expect(accessToken).toHaveBeenNthCalledWith(2, 'expired-token') + }) + + it('builds a transcript-only prompt with optional language guidance', () => { + expect(speechTranscriptionPrompt('')).not.toContain('expected language') + expect(speechTranscriptionPrompt('en')).toContain('expected language is en') + expect(speechTranscriptionPrompt('en')).toContain('Return only the transcript') + }) +}) diff --git a/src/main/services/gemini-cli-speech-to-text-service.ts b/src/main/services/gemini-cli-speech-to-text-service.ts new file mode 100644 index 000000000..3deb19cfb --- /dev/null +++ b/src/main/services/gemini-cli-speech-to-text-service.ts @@ -0,0 +1,239 @@ +import { randomUUID } from 'node:crypto' +import { + GEMINI_CLI_CODE_ASSIST_API_VERSION, + GEMINI_CLI_CODE_ASSIST_ENDPOINT +} from '../../../kun/src/adapters/model/gemini-cli-api-model-client.js' +import { GeminiCliOAuthSource } from '../../../kun/src/adapters/model/gemini-cli-oauth.js' +import type { KunSpeechToTextSettingsV1 } from '../../shared/app-settings' +import type { SpeechTranscriptionRequest } from '../../shared/speech-to-text' + +type GeminiCliOAuthTokenSource = Pick + +type GeminiCliSpeechOptions = { + fetchImpl?: typeof fetch + oauthSource?: GeminiCliOAuthTokenSource + endpoint?: string + apiVersion?: string +} + +type GeminiCodeAssistPayload = { + cloudaicompanionProject?: string + ineligibleTiers?: Array<{ reasonMessage?: string }> + response?: GeminiGenerateContentResponse + candidates?: GeminiGenerateContentResponse['candidates'] + error?: { + code?: number + status?: string + message?: string + } +} + +type GeminiGenerateContentResponse = { + candidates?: Array<{ + content?: { + parts?: Array<{ + text?: string + thought?: boolean + }> + } + }> +} + +/** + * Uses the same OAuth credential and Code Assist request contract as the + * official Gemini CLI. The credential stays in the OS keychain/CLI store and + * is never copied into Kun settings. + */ +export async function transcribeViaGeminiCliAudio( + speechToText: KunSpeechToTextSettingsV1, + request: SpeechTranscriptionRequest, + options: GeminiCliSpeechOptions = {} +): Promise { + const fetchImpl = options.fetchImpl ?? fetch + const oauthSource = options.oauthSource ?? new GeminiCliOAuthSource({ fetchImpl }) + const endpoint = ( + options.endpoint ?? + process.env.CODE_ASSIST_ENDPOINT?.trim() ?? + GEMINI_CLI_CODE_ASSIST_ENDPOINT + ).replace(/\/+$/, '') + const apiVersion = ( + options.apiVersion ?? + process.env.CODE_ASSIST_API_VERSION?.trim() ?? + GEMINI_CLI_CODE_ASSIST_API_VERSION + ).replace(/^\/+|\/+$/g, '') + const signal = AbortSignal.timeout(speechToText.timeoutMs) + + const model = speechToText.model + let accessToken = await oauthSource.accessToken() + let setup = await postGeminiCliJson( + fetchImpl, + `${endpoint}/${apiVersion}:loadCodeAssist`, + accessToken, + { + metadata: { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI' + } + }, + signal + ) + if (setup.status === 401) { + accessToken = await oauthSource.accessToken(accessToken) + setup = await postGeminiCliJson( + fetchImpl, + `${endpoint}/${apiVersion}:loadCodeAssist`, + accessToken, + { + metadata: { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI' + } + }, + signal + ) + } + assertGeminiCliResponse(setup) + const projectId = setup.payload.cloudaicompanionProject?.trim() + if (!projectId) { + const reason = setup.payload.ineligibleTiers + ?.map((tier) => tier.reasonMessage?.trim()) + .filter(Boolean) + .join('; ') + throw new Error( + reason || + 'Gemini CLI account setup is incomplete. Run `gemini` once to finish Google subscription onboarding.' + ) + } + + let generated = await postGeminiCliJson( + fetchImpl, + `${endpoint}/${apiVersion}:generateContent`, + accessToken, + { + model, + project: projectId, + user_prompt_id: randomUUID(), + request: { + contents: [{ + role: 'user', + parts: [ + { text: speechTranscriptionPrompt(speechToText.language) }, + { + inlineData: { + mimeType: request.mimeType, + data: request.audioBase64 + } + } + ] + }], + generationConfig: { + temperature: 0, + maxOutputTokens: 2_048 + }, + session_id: randomUUID() + } + }, + signal + ) + if (generated.status === 401) { + accessToken = await oauthSource.accessToken(accessToken) + generated = await postGeminiCliJson( + fetchImpl, + `${endpoint}/${apiVersion}:generateContent`, + accessToken, + { + model, + project: projectId, + user_prompt_id: randomUUID(), + request: { + contents: [{ + role: 'user', + parts: [ + { text: speechTranscriptionPrompt(speechToText.language) }, + { + inlineData: { + mimeType: request.mimeType, + data: request.audioBase64 + } + } + ] + }], + generationConfig: { + temperature: 0, + maxOutputTokens: 2_048 + }, + session_id: randomUUID() + } + }, + signal + ) + } + assertGeminiCliResponse(generated) + return geminiResponseText(generated.payload) +} + +export function speechTranscriptionPrompt(language: string): string { + const normalizedLanguage = language.trim() + const languageInstruction = + normalizedLanguage && normalizedLanguage !== 'auto' + ? ` The expected language is ${normalizedLanguage}.` + : '' + return [ + 'Transcribe the speech in this audio accurately.', + 'Return only the transcript, without commentary, labels, timestamps, or Markdown.', + languageInstruction + ].join('').trim() +} + +function geminiResponseText(payload: GeminiCodeAssistPayload): string { + const response = payload.response ?? payload + const text = response.candidates?.[0]?.content?.parts + ?.filter((part) => !part.thought && typeof part.text === 'string') + .map((part) => part.text) + .join('') + .trim() + if (!text) throw new Error('Gemini speech response has no transcript text') + return text +} + +async function postGeminiCliJson( + fetchImpl: typeof fetch, + url: string, + accessToken: string, + body: Record, + signal: AbortSignal +): Promise<{ status: number; ok: boolean; payload: GeminiCodeAssistPayload; rawBody: string }> { + const response = await fetchImpl(url, { + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json', + 'user-agent': 'google-gemini-cli', + 'x-goog-api-client': 'gl-node/kun gemini-cli-audio' + }, + body: JSON.stringify(body), + signal + }) + const rawBody = await response.text() + let payload: GeminiCodeAssistPayload = {} + try { + payload = JSON.parse(rawBody) as GeminiCodeAssistPayload + } catch { + if (response.ok) throw new Error('Gemini CLI speech response is not valid JSON') + } + return { status: response.status, ok: response.ok, payload, rawBody } +} + +function assertGeminiCliResponse( + result: { status: number; ok: boolean; payload: GeminiCodeAssistPayload; rawBody: string } +): void { + if (result.ok) return + const detail = + result.payload.error?.message?.trim() || + result.payload.error?.status?.trim() || + result.rawBody.replace(/\s+/g, ' ').trim() || + 'Unknown provider error' + throw new Error(`Gemini CLI speech request failed (HTTP ${result.status}): ${detail.slice(0, 1_000)}`) +} diff --git a/src/main/services/git-checkpoint-service.test.ts b/src/main/services/git-checkpoint-service.test.ts index fd3ec5d99..06113ba13 100644 --- a/src/main/services/git-checkpoint-service.test.ts +++ b/src/main/services/git-checkpoint-service.test.ts @@ -469,6 +469,98 @@ describe('git checkpoint service', () => { await expect(stat(join(dataDir, 'git-checkpoints', unused))).rejects.toThrow() }) + it('preserves referenced checkpoints when cleanup reaches maxPerThread', async () => { + const now = new Date('2026-07-25T12:00:00.000Z') + const ids = ['gcp_t1', 'gcp_t2', 'gcp_t3', 'gcp_t4'] + await mkdir(join(dataDir, 'threads', 'thr_cap'), { recursive: true }) + const lines: string[] = [] + for (let i = 0; i < ids.length; i += 1) { + const id = ids[i] + await mkdir(join(dataDir, 'git-checkpoints', id), { recursive: true }) + await writeFile( + join(dataDir, 'git-checkpoints', id, 'metadata.json'), + JSON.stringify({ + checkpointId: id, + threadId: 'thr_cap', + repositoryRoot: '/tmp/repo', + head: null, + currentBranch: null, + createdAt: `2026-07-25T0${i}:00:00.000Z`, + untrackedFiles: [] + }), + 'utf-8' + ) + lines.push(JSON.stringify({ id: `item_${i}`, workspaceCheckpointId: id })) + } + await writeFile(join(dataDir, 'threads', 'thr_cap', 'items.jsonl'), `${lines.join('\n')}\n`, 'utf-8') + + const result = await cleanupUnusedGitCheckpoints({ + dataDir, + graceMs: 0, + maxAgeDays: 3, + maxPerThread: 2, + now + }) + + expect(result.deletedIds).toEqual([]) + for (const id of ids) { + await expect(stat(join(dataDir, 'git-checkpoints', id))).resolves.toBeTruthy() + } + }) + + it('preserves referenced checkpoints after maxAgeDays', async () => { + const fresh = 'gcp_fresh_ref' + const stale = 'gcp_stale_ref' + const now = new Date('2026-07-25T12:00:00.000Z') + await mkdir(join(dataDir, 'git-checkpoints', fresh), { recursive: true }) + await mkdir(join(dataDir, 'git-checkpoints', stale), { recursive: true }) + await writeFile( + join(dataDir, 'git-checkpoints', fresh, 'metadata.json'), + JSON.stringify({ + checkpointId: fresh, + threadId: 'thr_1', + repositoryRoot: '/tmp/repo', + head: null, + currentBranch: null, + createdAt: '2026-07-24T12:00:00.000Z', + untrackedFiles: [] + }), + 'utf-8' + ) + await writeFile( + join(dataDir, 'git-checkpoints', stale, 'metadata.json'), + JSON.stringify({ + checkpointId: stale, + threadId: 'thr_1', + repositoryRoot: '/tmp/repo', + head: null, + currentBranch: null, + createdAt: '2026-07-20T12:00:00.000Z', + untrackedFiles: [] + }), + 'utf-8' + ) + await mkdir(join(dataDir, 'threads', 'thr_1'), { recursive: true }) + await writeFile( + join(dataDir, 'threads', 'thr_1', 'items.jsonl'), + `${JSON.stringify({ id: 'item_1', workspaceCheckpointId: fresh })}\n` + + `${JSON.stringify({ id: 'item_2', workspaceCheckpointId: stale })}\n`, + 'utf-8' + ) + + const result = await cleanupUnusedGitCheckpoints({ + dataDir, + graceMs: 0, + maxAgeDays: 3, + now + }) + + expect(result.deletedIds).toEqual([]) + expect(result.kept).toBe(2) + await expect(stat(join(dataDir, 'git-checkpoints', fresh))).resolves.toBeTruthy() + await expect(stat(join(dataDir, 'git-checkpoints', stale))).resolves.toBeTruthy() + }) + it('keeps recently created checkpoints (create-vs-flush grace) and deletes old ones', async () => { const fresh = 'gcp_fresh' const stale = 'gcp_stale' @@ -521,6 +613,52 @@ describe('git checkpoint service', () => { if (!second.due) throw new Error('expected cleanup to run after interval') expect(second.result.deletedIds).toEqual(['gcp_second']) }) + + it('force and app-version upgrades bypass the interval gate', async () => { + await mkdir(join(dataDir, 'git-checkpoints', 'gcp_a'), { recursive: true }) + const first = await cleanupUnusedGitCheckpointsIfDue({ + dataDir, + intervalDays: 3, + graceMs: 0, + appVersion: '0.1.0', + now: new Date('2026-01-01T00:00:00.000Z') + }) + expect(first.due).toBe(true) + + await mkdir(join(dataDir, 'git-checkpoints', 'gcp_b'), { recursive: true }) + const skipped = await cleanupUnusedGitCheckpointsIfDue({ + dataDir, + intervalDays: 3, + graceMs: 0, + appVersion: '0.1.0', + now: new Date('2026-01-01T12:00:00.000Z') + }) + expect(skipped.due).toBe(false) + + const forced = await cleanupUnusedGitCheckpointsIfDue({ + dataDir, + intervalDays: 3, + graceMs: 0, + force: true, + appVersion: '0.1.0', + now: new Date('2026-01-01T12:00:01.000Z') + }) + expect(forced.due).toBe(true) + if (!forced.due) throw new Error('expected forced cleanup') + expect(forced.result.deletedIds).toEqual(['gcp_b']) + + await mkdir(join(dataDir, 'git-checkpoints', 'gcp_c'), { recursive: true }) + const upgraded = await cleanupUnusedGitCheckpointsIfDue({ + dataDir, + intervalDays: 3, + graceMs: 0, + appVersion: '0.2.0', + now: new Date('2026-01-01T12:00:02.000Z') + }) + expect(upgraded.due).toBe(true) + if (!upgraded.due) throw new Error('expected version-upgrade cleanup') + expect(upgraded.result.deletedIds).toEqual(['gcp_c']) + }) }) describe('git checkpoint storage limits (issue #651)', () => { @@ -678,4 +816,43 @@ describe('git checkpoint storage limits (issue #651)', () => { await expect(stat(join(root, ids[2]))).resolves.toBeTruthy() await expect(stat(join(root, ids[3]))).resolves.toBeTruthy() }) + + it('keeps a message-referenced checkpoint when new checkpoints exceed the cap', async () => { + const ids: string[] = [] + for (let i = 0; i < 2; i += 1) { + const checkpoint = await createGitCheckpoint({ + dataDir, + workspaceRoot: repoRoot, + threadId: 'thr_referenced_cap', + checkpointId: `gcp_${2000 + i}_fixed-${i}`, + storage: { maxPerThread: 2 } + }) + if (!checkpoint.ok) throw new Error(checkpoint.message) + ids.push(checkpoint.checkpointId) + } + await mkdir(join(dataDir, 'threads', 'thr_referenced_cap'), { recursive: true }) + await writeFile( + join(dataDir, 'threads', 'thr_referenced_cap', 'items.jsonl'), + `${JSON.stringify({ id: 'item_1', workspaceCheckpointId: ids[0] })}\n`, + 'utf-8' + ) + + for (let i = 2; i < 4; i += 1) { + const checkpoint = await createGitCheckpoint({ + dataDir, + workspaceRoot: repoRoot, + threadId: 'thr_referenced_cap', + checkpointId: `gcp_${2000 + i}_fixed-${i}`, + storage: { maxPerThread: 2 } + }) + if (!checkpoint.ok) throw new Error(checkpoint.message) + ids.push(checkpoint.checkpointId) + } + + const root = join(dataDir, 'git-checkpoints') + await expect(stat(join(root, ids[0]))).resolves.toBeTruthy() + await expect(stat(join(root, ids[1]))).rejects.toThrow() + await expect(stat(join(root, ids[2]))).resolves.toBeTruthy() + await expect(stat(join(root, ids[3]))).resolves.toBeTruthy() + }) }) diff --git a/src/main/services/git-checkpoint-service.ts b/src/main/services/git-checkpoint-service.ts index b7ea0a722..7892c021c 100644 --- a/src/main/services/git-checkpoint-service.ts +++ b/src/main/services/git-checkpoint-service.ts @@ -71,6 +71,8 @@ export type GitCheckpointCleanupDueResult = type GitCheckpointCleanupState = { lastRunAt?: string + /** App version that last completed a cleanup pass (forces a run after upgrades). */ + lastAppVersion?: string } const DAY_MS = 24 * 60 * 60 * 1_000 @@ -481,14 +483,42 @@ function isCheckpointCleanupDue(lastRunAt: string | undefined, intervalDays: num // disable it with graceMs: 0. const CHECKPOINT_CLEANUP_GRACE_MS = 10 * 60 * 1_000 +async function resolveCheckpointCreatedMs(root: string, checkpointId: string): Promise { + const metadata = await readMetadata(root, checkpointId) + if (metadata) { + const createdMs = Date.parse(metadata.createdAt) + if (Number.isFinite(createdMs)) return createdMs + } + const named = checkpointNameTimestamp(checkpointId) + if (named > 0) return named + try { + const dirStat = await stat(join(root, checkpointId)) + return dirStat.mtimeMs + } catch { + return null + } +} + export async function cleanupUnusedGitCheckpoints(params: { dataDir: string checkpointsRoot?: string + /** Delete checkpoints older than this many days (by createdAt / name / mtime). */ + maxAgeDays?: number + /** Also enforce the per-thread cap across every thread in the store. */ + maxPerThread?: number graceMs?: number now?: Date }): Promise { const graceMs = params.graceMs ?? CHECKPOINT_CLEANUP_GRACE_MS const nowMs = (params.now ?? new Date()).getTime() + const maxAgeMs = + typeof params.maxAgeDays === 'number' && Number.isFinite(params.maxAgeDays) && params.maxAgeDays > 0 + ? params.maxAgeDays * DAY_MS + : null + const maxPerThread = + typeof params.maxPerThread === 'number' && Number.isFinite(params.maxPerThread) + ? Math.max(1, Math.min(100, Math.floor(params.maxPerThread))) + : DEFAULT_MAX_CHECKPOINTS_PER_THREAD const root = resolveCheckpointsRoot(params.dataDir, params.checkpointsRoot) const referenced = await collectReferencedCheckpointIds(params.dataDir) const result: GitCheckpointCleanupResult = { @@ -513,11 +543,16 @@ export async function cleanupUnusedGitCheckpoints(params: { if (!entry.isDirectory()) continue const checkpointId = entry.name result.scanned += 1 + const createdMs = await resolveCheckpointCreatedMs(root, checkpointId) + const expiredByAge = + maxAgeMs != null && createdMs != null && nowMs - createdMs >= maxAgeMs + // A message may expose this checkpoint as its rollback target. Retain it + // regardless of its age so cleanup can never leave a broken rollback link. if (referenced.has(checkpointId)) { result.kept += 1 continue } - if (graceMs > 0) { + if (!expiredByAge && graceMs > 0) { try { const dirStat = await stat(join(root, checkpointId)) if (nowMs - dirStat.mtimeMs < graceMs) { @@ -539,6 +574,17 @@ export async function cleanupUnusedGitCheckpoints(params: { } } + // Keep all checkpoints that remain reachable from thread history. The cap is + // deliberately a soft limit over unreferenced directories only: deleting a + // referenced checkpoint would turn an existing rollback action into data loss. + const pruned = await pruneAllThreadCheckpoints(root, maxPerThread, referenced) + for (const checkpointId of pruned.deleted) { + if (result.deletedIds.includes(checkpointId)) continue + result.deleted += 1 + result.deletedIds.push(checkpointId) + result.kept = Math.max(0, result.kept - 1) + } + return result } @@ -548,22 +594,41 @@ export async function cleanupUnusedGitCheckpointsIfDue(params: { intervalDays: number now?: Date graceMs?: number + maxPerThread?: number + /** + * When true, skip the interval gate (used on app startup so retention always + * runs once per launch). Age pruning still uses `intervalDays`. + */ + force?: boolean + /** When set and different from the last recorded version, force a cleanup pass. */ + appVersion?: string }): Promise { const now = params.now ?? new Date() const root = resolveCheckpointsRoot(params.dataDir, params.checkpointsRoot) const state = await readCleanupState(root) const lastRunAt = typeof state.lastRunAt === 'string' ? state.lastRunAt : undefined - if (!isCheckpointCleanupDue(lastRunAt, params.intervalDays, now)) { + const appVersion = typeof params.appVersion === 'string' ? params.appVersion.trim() : '' + const versionChanged = Boolean(appVersion) && state.lastAppVersion !== appVersion + if (!params.force && !versionChanged && !isCheckpointCleanupDue(lastRunAt, params.intervalDays, now)) { return { due: false, lastRunAt: lastRunAt ?? null } } const result = await cleanupUnusedGitCheckpoints({ dataDir: params.dataDir, ...(params.checkpointsRoot ? { checkpointsRoot: params.checkpointsRoot } : {}), + maxAgeDays: params.intervalDays, + ...(params.maxPerThread !== undefined ? { maxPerThread: params.maxPerThread } : {}), now, ...(params.graceMs !== undefined ? { graceMs: params.graceMs } : {}) }) const nextLastRunAt = now.toISOString() - await writeCleanupState(root, { lastRunAt: nextLastRunAt }) + await writeCleanupState(root, { + lastRunAt: nextLastRunAt, + ...(appVersion + ? { lastAppVersion: appVersion } + : typeof state.lastAppVersion === 'string' + ? { lastAppVersion: state.lastAppVersion } + : {}) + }) return { due: true, lastRunAt: nextLastRunAt, result } } @@ -650,8 +715,11 @@ export async function createGitCheckpoint(params: { await writeFile(join(dir, 'metadata.json'), JSON.stringify(metadata, null, 2), 'utf-8') const manifest = await createCheckpointManifestV1({ metadata, workspaceRoot }) await writeFile(manifestPath(root, checkpointId), JSON.stringify(manifest, null, 2), 'utf-8') - // Bound per-thread retention so an active thread cannot grow unboundedly. - await pruneThreadCheckpoints(root, params.threadId, maxPerThread, checkpointId).catch(() => undefined) + // Retention may only remove checkpoints that thread history no longer + // references. This keeps all visible rollback actions restorable. + const referenced = await collectReferencedCheckpointIds(params.dataDir) + await pruneThreadCheckpoints(root, params.threadId, maxPerThread, checkpointId, referenced) + .catch(() => undefined) return { ok: true, checkpointId, repositoryRoot, head, currentBranch } } catch (error) { const failure = checkpointFailure(error) @@ -663,15 +731,18 @@ export async function createGitCheckpoint(params: { } /** - * Keep at most `max` checkpoints for a thread (issue #651, per-thread cap). - * Oldest checkpoints (by createdAt, falling back to the `gcp__` name) are - * removed first; `keepId` (the just-created checkpoint) is always retained. + * Keep at most `max` unreferenced checkpoints for a thread. Checkpoints still + * referenced by saved messages are never pruned: they are user-visible rollback + * targets, so the cap is intentionally soft when thread history needs them. + * Oldest eligible checkpoints (by createdAt, falling back to the + * `gcp__` name) are removed first; `keepId` is always retained. */ export async function pruneThreadCheckpoints( root: string, threadId: string, max: number, - keepId?: string + keepId?: string, + referencedIds: ReadonlySet = new Set() ): Promise<{ deleted: string[] }> { if (max <= 0) return { deleted: [] } let entries: Dirent[] @@ -689,12 +760,20 @@ export async function pruneThreadCheckpoints( const order = Number.isFinite(createdMs) ? createdMs : checkpointNameTimestamp(entry.name) owned.push({ id: entry.name, order }) } - // Newest first; keep the first `max`, delete the rest (never the keepId). + // Newest first; keep the first `max` unreferenced entries. Referenced + // checkpoints and the just-created checkpoint are always protected. owned.sort((a, b) => b.order - a.order) const deleted: string[] = [] - for (let i = 0; i < owned.length; i += 1) { - const { id } = owned[i] - if (i < max || id === keepId) continue + const keepIdCountsTowardCap = Boolean( + keepId && !referencedIds.has(keepId) && owned.some(({ id }) => id === keepId) + ) + let keptUnreferenced = keepIdCountsTowardCap ? 1 : 0 + for (const { id } of owned) { + if (id === keepId || referencedIds.has(id)) continue + if (keptUnreferenced < max) { + keptUnreferenced += 1 + continue + } try { await rm(checkpointDir(root, id), { recursive: true, force: true }) deleted.push(id) @@ -705,6 +784,51 @@ export async function pruneThreadCheckpoints( return { deleted } } +/** Enforce the unreferenced checkpoint cap for every thread under `root`. */ +export async function pruneAllThreadCheckpoints( + root: string, + max: number, + referencedIds: ReadonlySet = new Set() +): Promise<{ deleted: string[] }> { + if (max <= 0) return { deleted: [] } + let entries: Dirent[] + try { + entries = await readdir(root, { withFileTypes: true }) + } catch { + return { deleted: [] } + } + const byThread = new Map>() + for (const entry of entries) { + if (!entry.isDirectory()) continue + const metadata = await readMetadata(root, entry.name) + if (!metadata?.threadId) continue + const createdMs = Date.parse(metadata.createdAt) + const order = Number.isFinite(createdMs) ? createdMs : checkpointNameTimestamp(entry.name) + const list = byThread.get(metadata.threadId) ?? [] + list.push({ id: entry.name, order }) + byThread.set(metadata.threadId, list) + } + const deleted: string[] = [] + for (const owned of byThread.values()) { + owned.sort((a, b) => b.order - a.order) + let keptUnreferenced = 0 + for (const { id } of owned) { + if (referencedIds.has(id)) continue + if (keptUnreferenced < max) { + keptUnreferenced += 1 + continue + } + try { + await rm(checkpointDir(root, id), { recursive: true, force: true }) + deleted.push(id) + } catch { + // best-effort + } + } + } + return { deleted } +} + /** Extract the `gcp__` creation epoch for ordering fallback. */ function checkpointNameTimestamp(name: string): number { const match = name.match(/^gcp_(\d+)_/) diff --git a/src/main/services/office-document-service.test.ts b/src/main/services/office-document-service.test.ts new file mode 100644 index 000000000..1d19ef919 --- /dev/null +++ b/src/main/services/office-document-service.test.ts @@ -0,0 +1,144 @@ +import { createHash } from 'node:crypto' +import { createWriteStream } from 'node:fs' +import { mkdtemp, readFile, rm, truncate } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ZipFile } from 'yazl' +import { MAX_RUNTIME_DOCUMENT_SOURCE_BYTES } from '../../shared/office-document' +import { readLocalOfficeDocument } from './office-document-service' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function ooxmlFixture( + extension: 'docx' | 'xlsx' | 'pptx', + contentTypeExtension: 'docx' | 'xlsx' | 'pptx' = extension +): Promise { + const root = await mkdtemp(join(tmpdir(), 'kun-office-document-')) + roots.push(root) + const filePath = join(root, `fixture.${extension}`) + const mainContentType = { + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml' + }[contentTypeExtension] + const zip = new ZipFile() + zip.addBuffer(Buffer.from( + `` + ), '[Content_Types].xml') + zip.addBuffer(Buffer.from(''), 'main.xml') + await new Promise((resolveWrite, rejectWrite) => { + zip.outputStream + .pipe(createWriteStream(filePath)) + .once('close', resolveWrite) + .once('error', rejectWrite) + zip.end() + }) + return filePath +} + +function successfulRun() { + return vi.fn(async (args: string[]) => { + if (args[0] === 'validate') return { stdout: '{"valid":true}', stderr: '', exitCode: 0 } + if (args[2] === 'stats') return { stdout: '{"sheetCount":3}', stderr: '', exitCode: 0 } + if (args[2] === 'html') return { stdout: 'Workbook', stderr: '', exitCode: 0 } + return { stdout: 'Sheet1\\nA1 = 42\\nA2 = =SUM(A1:A1)', stderr: '', exitCode: 0 } + }) +} + +describe('Office document intake', () => { + it('verifies OOXML content, extracts semantics, hashes the source, and returns a visual preview', async () => { + const filePath = await ooxmlFixture('xlsx') + const source = await readFile(filePath) + const runOfficeCli = successfulRun() + const renderHtml = vi.fn(async () => ({ + dataBase64: Buffer.from('preview').toString('base64'), + mimeType: 'image/webp' as const, + byteSize: 7, + width: 800, + height: 600, + wasCompressed: true + })) + + const result = await readLocalOfficeDocument({ path: filePath }, { + runOfficeCli, + renderHtml + }) + + expect(result).toMatchObject({ + ok: true, + path: filePath, + name: 'fixture.xlsx', + format: 'xlsx', + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + sourceSha256: createHash('sha256').update(source).digest('hex'), + documentText: expect.stringContaining('A1 = 42'), + pageCount: 3, + truncated: false, + visualPreview: expect.objectContaining({ + mimeType: 'image/webp', + byteSize: 7 + }) + }) + expect(runOfficeCli.mock.calls.map(([args]) => args.slice(0, 3))).toEqual([ + ['validate', filePath, '--json'], + ['view', filePath, 'text'], + ['view', filePath, 'stats'], + ['view', filePath, 'html'] + ]) + expect(renderHtml).toHaveBeenCalledWith('Workbook') + }) + + it('rejects an OOXML package whose declared content does not match its extension', async () => { + const filePath = await ooxmlFixture('xlsx', 'docx') + const runOfficeCli = successfulRun() + + const result = await readLocalOfficeDocument({ path: filePath }, { + runOfficeCli, + renderHtml: vi.fn() + }) + + expect(result).toMatchObject({ + ok: false, + code: 'office_document_failed', + message: expect.stringContaining('does not match the .xlsx') + }) + expect(runOfficeCli).not.toHaveBeenCalled() + }) + + it('degrades to semantic-only output when visual rendering fails', async () => { + const filePath = await ooxmlFixture('docx') + const result = await readLocalOfficeDocument({ path: filePath }, { + runOfficeCli: successfulRun(), + renderHtml: vi.fn(async () => { + throw new Error('renderer unavailable') + }) + }) + + expect(result).toMatchObject({ + ok: true, + format: 'docx', + documentText: expect.any(String), + previewUnavailableReason: 'renderer unavailable' + }) + if (result.ok) expect(result.visualPreview).toBeUndefined() + }) + + it('rejects oversized Office attachments before invoking OfficeCLI', async () => { + const filePath = await ooxmlFixture('pptx') + await truncate(filePath, MAX_RUNTIME_DOCUMENT_SOURCE_BYTES + 1) + const runOfficeCli = successfulRun() + + const result = await readLocalOfficeDocument({ path: filePath }, { + runOfficeCli, + renderHtml: vi.fn() + }) + + expect(result).toMatchObject({ ok: false, code: 'file_too_large' }) + expect(runOfficeCli).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/services/office-document-service.ts b/src/main/services/office-document-service.ts new file mode 100644 index 000000000..aa7c2339e --- /dev/null +++ b/src/main/services/office-document-service.ts @@ -0,0 +1,469 @@ +import { createHash, randomUUID } from 'node:crypto' +import { spawn, type ChildProcess } from 'node:child_process' +import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { tmpdir } from 'node:os' +import { app, BrowserWindow } from 'electron' +import sharp from 'sharp' +import yauzl from 'yauzl' +import { + MAX_RUNTIME_DOCUMENT_SOURCE_BYTES, + MAX_RUNTIME_DOCUMENT_TEXT_CHARS, + officeDocumentFormatFromName, + officeDocumentMimeType, + type LocalOfficeDocumentReadResult, + type LocalOfficeDocumentTarget, + type OfficeDocumentFormat, + type OfficeDocumentVisualPreview +} from '../../shared/office-document' + +const OFFICECLI_TIMEOUT_MS = 60_000 +const OFFICECLI_MAX_OUTPUT_BYTES = 2 * 1024 * 1024 +const OFFICECLI_MAX_CONCURRENCY = 2 +const OFFICECLI_VERSION = '1.0.141' +const OOXML_CONTENT_TYPES_MAX_BYTES = 256 * 1024 +// The runtime's default preview/fallback limit is measured in Base64 +// characters. 384 KiB encodes to exactly 512 KiB, so keep the binary side at +// or below that decoded ceiling. +const VISUAL_PREVIEW_MAX_BYTES = 384 * 1024 +const VISUAL_PREVIEW_MAX_DIMENSION = 1920 +let activeOfficeCliProcesses = 0 +const officeCliProcessWaiters: Array<() => void> = [] + +type OfficeCliResult = { + stdout: string + stderr: string + exitCode: number +} + +type OfficeDocumentServiceDependencies = { + binaryPath?: string + runOfficeCli?: (args: string[]) => Promise + renderHtml?: (html: string) => Promise + signal?: AbortSignal +} + +const EXPECTED_MAIN_CONTENT_TYPE: Record = { + docx: 'wordprocessingml.document.main+xml', + xlsx: 'spreadsheetml.sheet.main+xml', + pptx: 'presentationml.presentation.main+xml' +} + +export async function readLocalOfficeDocument( + target: LocalOfficeDocumentTarget, + dependencies: OfficeDocumentServiceDependencies = {} +): Promise { + try { + const filePath = target.path.trim() + const format = officeDocumentFormatFromName(filePath) + if (!filePath || !format) { + return { ok: false, code: 'unsupported_type', message: 'Expected a .docx, .xlsx, or .pptx file.' } + } + const fileStat = await stat(filePath) + if (!fileStat.isFile()) { + return { ok: false, code: 'not_a_file', message: 'Office document path is not a regular file.' } + } + if (fileStat.size <= 0) { + return { ok: false, code: 'empty_file', message: 'Office document is empty.' } + } + if (fileStat.size > MAX_RUNTIME_DOCUMENT_SOURCE_BYTES) { + return { + ok: false, + code: 'file_too_large', + message: `Office document exceeds the ${MAX_RUNTIME_DOCUMENT_SOURCE_BYTES} byte attachment limit.` + } + } + await assertOoxmlPackageType(filePath, format) + const source = await readFile(filePath) + const sourceSha256 = createHash('sha256').update(source).digest('hex') + const run = dependencies.runOfficeCli ?? + ((args) => runOfficeCli( + dependencies.binaryPath || 'officecli', + args, + dependencies.signal + )) + + const validation = await run(['validate', filePath, '--json']) + assertOfficeCliSuccess(validation, 'Office document validation failed') + + const semanticArgs = semanticViewArgs(filePath, format) + const semantic = await run(semanticArgs) + assertOfficeCliSuccess(semantic, 'Office document text extraction failed') + const rawText = semantic.stdout.trim() + if (!rawText) throw new Error('OfficeCLI returned no semantic document content.') + const truncated = rawText.length > MAX_RUNTIME_DOCUMENT_TEXT_CHARS + const documentText = truncated + ? rawText.slice(0, MAX_RUNTIME_DOCUMENT_TEXT_CHARS) + : rawText + + const statsResult = await run(['view', filePath, 'stats', '--json']).catch(() => null) + const pageCount = statsResult?.exitCode === 0 + ? extractPageCount(statsResult.stdout, format) + : undefined + + let visualPreview: OfficeDocumentVisualPreview | undefined + let previewUnavailableReason: string | undefined + try { + visualPreview = dependencies.renderHtml + ? undefined + : await readCachedOfficePreview(sourceSha256) + if (!visualPreview) { + const htmlResult = await run(['view', filePath, 'html']) + assertOfficeCliSuccess(htmlResult, 'Office document HTML preview failed') + visualPreview = await (dependencies.renderHtml ?? renderOfficeHtmlPreview)(htmlResult.stdout) + if (!dependencies.renderHtml) await writeCachedOfficePreview(sourceSha256, visualPreview) + } + } catch (error) { + previewUnavailableReason = boundedErrorMessage(error) + } + + return { + ok: true, + path: filePath, + name: basename(filePath), + format, + mimeType: officeDocumentMimeType(format), + size: fileStat.size, + mtimeMs: fileStat.mtimeMs, + sourceSha256, + documentText, + ...(pageCount ? { pageCount } : {}), + truncated, + ...(visualPreview ? { visualPreview } : {}), + ...(previewUnavailableReason ? { previewUnavailableReason } : {}) + } + } catch (error) { + return { ok: false, code: 'office_document_failed', message: boundedErrorMessage(error) } + } +} + +function semanticViewArgs(filePath: string, format: OfficeDocumentFormat): string[] { + if (format === 'docx') return ['view', filePath, 'annotated'] + if (format === 'xlsx') return ['view', filePath, 'text', '--max-lines', '4000'] + return ['view', filePath, 'outline'] +} + +function assertOfficeCliSuccess(result: OfficeCliResult, fallback: string): void { + if (result.exitCode === 0) return + const detail = result.stderr.trim() || result.stdout.trim() + throw new Error(detail ? `${fallback}: ${detail}` : fallback) +} + +async function runOfficeCli( + binaryPath: string, + args: string[], + signal?: AbortSignal +): Promise { + if (signal?.aborted) throw abortError() + const release = await acquireOfficeCliProcessSlot() + const profileDir = join(app.getPath('userData'), 'runtime', 'officecli-profile') + try { + await mkdir(profileDir, { recursive: true, mode: 0o700 }) + return await new Promise((resolve, reject) => { + let child: ChildProcess + try { + child = spawn(binaryPath, args, { + shell: false, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + env: officeCliEnvironment(profileDir) + }) + } catch (error) { + reject(error) + return + } + let stdout: Buffer = Buffer.alloc(0) + let stderr: Buffer = Buffer.alloc(0) + let settled = false + let timeout: NodeJS.Timeout | undefined + const finish = (result: () => void): void => { + if (settled) return + settled = true + if (timeout) clearTimeout(timeout) + signal?.removeEventListener('abort', onAbort) + result() + } + const onAbort = (): void => { + child.kill() + finish(() => reject(abortError())) + } + const append = ( + current: Buffer, + chunk: Buffer + ): Buffer => { + if (current.length + chunk.length > OFFICECLI_MAX_OUTPUT_BYTES) { + child.kill() + finish(() => reject(new Error(`OfficeCLI output exceeds ${OFFICECLI_MAX_OUTPUT_BYTES} bytes.`))) + return current + } + return Buffer.concat([current, chunk]) + } + child.stdout?.on('data', (chunk: Buffer) => { stdout = append(stdout, chunk) }) + child.stderr?.on('data', (chunk: Buffer) => { stderr = append(stderr, chunk) }) + child.once('error', (error) => finish(() => reject(error))) + child.once('close', (code) => finish(() => resolve({ + stdout: stdout.toString('utf8'), + stderr: stderr.toString('utf8'), + exitCode: code ?? 1 + }))) + signal?.addEventListener('abort', onAbort, { once: true }) + timeout = setTimeout(() => { + child.kill() + finish(() => reject(new Error(`OfficeCLI timed out after ${OFFICECLI_TIMEOUT_MS}ms.`))) + }, OFFICECLI_TIMEOUT_MS) + }) + } finally { + release() + } +} + +function abortError(): Error { + const error = new Error('OfficeCLI operation was cancelled.') + error.name = 'AbortError' + return error +} + +async function acquireOfficeCliProcessSlot(): Promise<() => void> { + if (activeOfficeCliProcesses >= OFFICECLI_MAX_CONCURRENCY) { + await new Promise((resolveWaiter) => officeCliProcessWaiters.push(resolveWaiter)) + } + activeOfficeCliProcesses += 1 + return () => { + activeOfficeCliProcesses = Math.max(0, activeOfficeCliProcesses - 1) + officeCliProcessWaiters.shift()?.() + } +} + +function officeCliEnvironment(profileDir: string): NodeJS.ProcessEnv { + return { + ...process.env, + OFFICECLI_SKIP_UPDATE: '1', + OFFICECLI_NO_AUTO_INSTALL: '1', + OFFICECLI_NO_AUTO_RESIDENT: '1', + OFFICECLI_RESIDENT_FLUSH: 'each', + HOME: profileDir, + USERPROFILE: profileDir, + APPDATA: profileDir, + LOCALAPPDATA: profileDir, + XDG_CONFIG_HOME: profileDir + } +} + +export async function renderOfficeHtmlPreview(html: string): Promise { + const previewRoot = join(tmpdir(), 'kun-office-preview') + const previewId = randomUUID() + const htmlPath = join(previewRoot, `${previewId}.html`) + await mkdir(previewRoot, { recursive: true, mode: 0o700 }) + await writeFile(htmlPath, html, { encoding: 'utf8', mode: 0o600 }) + const previewWindow = new BrowserWindow({ + show: false, + width: 1280, + height: 960, + webPreferences: { + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + webSecurity: true, + backgroundThrottling: false, + partition: `office-preview-${previewId}` + } + }) + try { + previewWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) + previewWindow.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => { + callback(false) + }) + previewWindow.webContents.session.webRequest.onBeforeRequest((details, callback) => { + const allowed = details.url.startsWith('file:') || details.url.startsWith('data:') || details.url === 'about:blank' + callback({ cancel: !allowed }) + }) + await previewWindow.loadFile(htmlPath) + previewWindow.webContents.on('will-navigate', (event) => event.preventDefault()) + const dimensions = await previewWindow.webContents.executeJavaScript(` + Promise.resolve(document.fonts && document.fonts.ready).then(() => ({ + width: Math.max(document.documentElement.scrollWidth, document.body ? document.body.scrollWidth : 0), + height: Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0) + })) + `) as { width?: number; height?: number } + const width = Math.max(320, Math.min(VISUAL_PREVIEW_MAX_DIMENSION, Math.ceil(dimensions.width || 1280))) + const height = Math.max(240, Math.min(VISUAL_PREVIEW_MAX_DIMENSION, Math.ceil(dimensions.height || 960))) + previewWindow.setContentSize(width, height) + const image = await previewWindow.webContents.capturePage() + const prepared = await encodePreviewWithinLimit(image.toPNG(), width, height) + return prepared + } finally { + if (!previewWindow.isDestroyed()) previewWindow.destroy() + await rm(htmlPath, { force: true }).catch(() => undefined) + } +} + +async function encodePreviewWithinLimit( + png: Buffer, + sourceWidth: number, + sourceHeight: number +): Promise { + let dimension = Math.min(VISUAL_PREVIEW_MAX_DIMENSION, Math.max(sourceWidth, sourceHeight)) + for (;;) { + for (const quality of [82, 74, 66, 58, 50, 42, 34]) { + const encoded = await sharp(png) + .resize({ width: dimension, height: dimension, fit: 'inside', withoutEnlargement: true }) + .webp({ quality, effort: 4 }) + .toBuffer({ resolveWithObject: true }) + if (encoded.data.length <= VISUAL_PREVIEW_MAX_BYTES) { + return { + dataBase64: encoded.data.toString('base64'), + mimeType: 'image/webp', + byteSize: encoded.data.length, + width: encoded.info.width, + height: encoded.info.height, + wasCompressed: true + } + } + } + if (dimension <= 320) break + dimension = Math.max(320, Math.floor(dimension * 0.8)) + } + throw new Error(`Office preview exceeds ${VISUAL_PREVIEW_MAX_BYTES} bytes after compression.`) +} + +async function readCachedOfficePreview( + sourceSha256: string +): Promise { + const cachePath = officePreviewCachePath(sourceSha256) + try { + const parsed = JSON.parse(await readFile(cachePath, 'utf8')) as OfficeDocumentVisualPreview + if ( + (parsed.mimeType !== 'image/png' && parsed.mimeType !== 'image/webp') || + typeof parsed.dataBase64 !== 'string' || + typeof parsed.byteSize !== 'number' || + parsed.byteSize <= 0 || + parsed.byteSize > VISUAL_PREVIEW_MAX_BYTES || + Buffer.byteLength(parsed.dataBase64, 'base64') !== parsed.byteSize + ) { + return undefined + } + return parsed + } catch { + return undefined + } +} + +async function writeCachedOfficePreview( + sourceSha256: string, + preview: OfficeDocumentVisualPreview +): Promise { + const cachePath = officePreviewCachePath(sourceSha256) + const temporaryPath = `${cachePath}.${randomUUID()}.tmp` + try { + await mkdir(join(app.getPath('userData'), 'cache', 'office-preview'), { + recursive: true, + mode: 0o700 + }) + await writeFile(temporaryPath, JSON.stringify(preview), { encoding: 'utf8', mode: 0o600 }) + await rename(temporaryPath, cachePath) + } finally { + await rm(temporaryPath, { force: true }).catch(() => undefined) + } +} + +function officePreviewCachePath(sourceSha256: string): string { + return join( + app.getPath('userData'), + 'cache', + 'office-preview', + `${sourceSha256}-${OFFICECLI_VERSION}.json` + ) +} + +async function assertOoxmlPackageType(filePath: string, format: OfficeDocumentFormat): Promise { + const contentTypes = await readOoxmlContentTypes(filePath) + if (!contentTypes.includes(EXPECTED_MAIN_CONTENT_TYPE[format])) { + throw new Error(`File content does not match the .${format} OOXML format.`) + } +} + +async function readOoxmlContentTypes(filePath: string): Promise { + return new Promise((resolve, reject) => { + yauzl.open(filePath, { lazyEntries: true, autoClose: true }, (openError, zip) => { + if (openError || !zip) { + reject(openError ?? new Error('Could not open OOXML package.')) + return + } + let settled = false + const finish = (callback: () => void): void => { + if (settled) return + settled = true + callback() + } + zip.once('error', (error) => finish(() => reject(error))) + zip.once('end', () => finish(() => reject(new Error('OOXML package is missing [Content_Types].xml.')))) + zip.on('entry', (entry) => { + if (entry.fileName !== '[Content_Types].xml') { + zip.readEntry() + return + } + if (entry.uncompressedSize > OOXML_CONTENT_TYPES_MAX_BYTES) { + finish(() => reject(new Error('OOXML content types manifest is unexpectedly large.'))) + return + } + zip.openReadStream(entry, (streamError, stream) => { + if (streamError || !stream) { + finish(() => reject(streamError ?? new Error('Could not read OOXML content types.'))) + return + } + const chunks: Buffer[] = [] + let total = 0 + stream.on('data', (chunk: Buffer) => { + total += chunk.length + if (total > OOXML_CONTENT_TYPES_MAX_BYTES) { + stream.destroy(new Error('OOXML content types manifest exceeds the read limit.')) + return + } + chunks.push(chunk) + }) + stream.once('error', (error) => finish(() => reject(error))) + stream.once('end', () => finish(() => resolve(Buffer.concat(chunks).toString('utf8')))) + }) + }) + zip.readEntry() + }) + }) +} + +function extractPageCount(raw: string, format: OfficeDocumentFormat): number | undefined { + try { + const parsed = JSON.parse(raw) as unknown + const preferredKeys = format === 'pptx' + ? ['slides', 'slideCount', 'slide_count'] + : format === 'xlsx' + ? ['sheets', 'sheetCount', 'sheet_count'] + : ['pages', 'pageCount', 'page_count'] + const found = findPositiveInteger(parsed, new Set(preferredKeys.map((key) => key.toLowerCase()))) + if (found) return found + } catch { + // Fall through to the bounded text pattern. + } + const label = format === 'pptx' ? 'slides?' : format === 'xlsx' ? 'sheets?' : 'pages?' + const match = new RegExp(`${label}\\s*[:=]\\s*(\\d+)`, 'i').exec(raw) + const count = match ? Number.parseInt(match[1] ?? '', 10) : 0 + return Number.isSafeInteger(count) && count > 0 ? count : undefined +} + +function findPositiveInteger(value: unknown, keys: Set, depth = 0): number | undefined { + if (depth > 5 || !value || typeof value !== 'object') return undefined + for (const [key, item] of Object.entries(value as Record)) { + if (keys.has(key.toLowerCase()) && typeof item === 'number' && Number.isSafeInteger(item) && item > 0) { + return item + } + } + for (const item of Object.values(value as Record)) { + const found = findPositiveInteger(item, keys, depth + 1) + if (found) return found + } + return undefined +} + +function boundedErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + return message.length > 2_000 ? `${message.slice(0, 2_000)}…` : message +} diff --git a/src/main/services/prompt-optimization-service.test.ts b/src/main/services/prompt-optimization-service.test.ts index 06c6e339e..b84fca9e0 100644 --- a/src/main/services/prompt-optimization-service.test.ts +++ b/src/main/services/prompt-optimization-service.test.ts @@ -39,7 +39,7 @@ function createSettings(patch: Partial = {}): Ap enabled: true, retentionDays: 2 }, - checkpointCleanup: { enabled: false, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, notifications: { turnComplete: true }, diff --git a/src/main/services/runtime-image-attachment-service.ts b/src/main/services/runtime-image-attachment-service.ts index f40a9ee02..96a2ceb4c 100644 --- a/src/main/services/runtime-image-attachment-service.ts +++ b/src/main/services/runtime-image-attachment-service.ts @@ -85,10 +85,13 @@ const attachmentMetadataSchema: z.ZodType = z.ob width: z.number().int().positive().optional(), height: z.number().int().positive().optional(), documentText: z.string().optional(), + documentFormat: z.enum(['pdf', 'docx', 'xlsx', 'pptx', 'text', 'csv', 'json', 'xml']).optional(), + sourceSha256: z.string().regex(/^[a-f0-9]{64}$/).optional(), pageCount: z.number().int().positive().optional(), truncated: z.boolean().optional(), localFilePath: z.string().optional(), textFallback: textFallbackSchema.optional(), + visualPreview: textFallbackSchema.optional(), threadIds: z.array(z.string()).optional(), workspaces: z.array(z.string()).optional(), createdAt: z.string(), diff --git a/src/main/services/skill-service.test.ts b/src/main/services/skill-service.test.ts index 5027e9366..6ca769681 100644 --- a/src/main/services/skill-service.test.ts +++ b/src/main/services/skill-service.test.ts @@ -343,7 +343,7 @@ describe('skill-service', () => { workspaceRoot, conversationWorkspaceRoot: '~/Documents/Kun', log: { enabled: false, retentionDays: 7 }, - checkpointCleanup: { enabled: false, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, notifications: { turnComplete: true }, appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, keyboardShortcuts: defaultKeyboardShortcuts(), diff --git a/src/main/services/speech-to-text-service.test.ts b/src/main/services/speech-to-text-service.test.ts index 6ebf5f6b8..114833d46 100644 --- a/src/main/services/speech-to-text-service.test.ts +++ b/src/main/services/speech-to-text-service.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { AppSettingsV1 } from '../../shared/app-settings' -import { isSpeechToTextConfigured, requestSpeechTranscription } from './speech-to-text-service' +import { isSpeechToTextConfigured } from '../../shared/speech-to-text' +import { requestSpeechTranscription } from './speech-to-text-service' const AUDIO_BASE64 = Buffer.from('fake-wav-bytes').toString('base64') @@ -49,6 +50,8 @@ describe('speech-to-text service', () => { expect(isSpeechToTextConfigured({ enabled: false, protocol: 'mimo-asr', baseUrl: 'x', apiKey: 'y', model: 'z' })).toBe(false) expect(isSpeechToTextConfigured({ enabled: true, protocol: 'mimo-asr', baseUrl: '', apiKey: 'y', model: 'z' })).toBe(false) expect(isSpeechToTextConfigured({ enabled: true, protocol: 'local-whisper', baseUrl: '', apiKey: '', model: 'whisper-small-q5_1' })).toBe(true) + expect(isSpeechToTextConfigured({ enabled: true, protocol: 'xai-stt', baseUrl: 'x', apiKey: 'y', model: '' })).toBe(true) + expect(isSpeechToTextConfigured({ enabled: true, protocol: 'gemini-cli-audio', baseUrl: '', apiKey: '', model: 'gemini-2.5-flash' })).toBe(true) }) it('transcribes via MiMo ASR chat completions with a base64 data URI', async () => { @@ -132,6 +135,110 @@ describe('speech-to-text service', () => { expect(form.get('file')).toBeInstanceOf(Blob) }) + it('transcribes via the dedicated xAI STT multipart endpoint', async () => { + const { fetchImpl, requests } = fakeFetch({ text: 'hello from Grok' }) + const result = await requestSpeechTranscription( + settingsWithSpeech({ + protocol: 'xai-stt', + baseUrl: 'https://api.x.ai/v1', + apiKey: 'xai-key', + model: 'grok-transcribe', + language: 'en' + }), + { audioBase64: AUDIO_BASE64, mimeType: 'audio/wav' }, + { fetchImpl } + ) + + expect(result).toEqual({ ok: true, text: 'hello from Grok' }) + expect(requests[0].url).toBe('https://api.x.ai/v1/stt') + const headers = requests[0].init.headers as Record + expect(headers.Authorization).toBe('Bearer xai-key') + const form = requests[0].init.body as FormData + expect(form.get('format')).toBe('true') + expect(form.get('language')).toBe('en') + expect(form.get('file')).toBeInstanceOf(Blob) + expect(form.get('model')).toBeNull() + }) + + it('does not send unsupported xAI formatting languages', async () => { + const { fetchImpl, requests } = fakeFetch({ text: '你好' }) + await requestSpeechTranscription( + settingsWithSpeech({ + protocol: 'xai-stt', + baseUrl: 'https://api.x.ai/v1', + apiKey: 'xai-key', + model: 'grok-transcribe', + language: 'zh' + }), + { audioBase64: AUDIO_BASE64, mimeType: 'audio/wav' }, + { fetchImpl } + ) + const form = requests[0].init.body as FormData + expect(form.get('format')).toBeNull() + expect(form.get('language')).toBeNull() + }) + + it('transcribes via Gemini native inline audio', async () => { + const { fetchImpl, requests } = fakeFetch({ + candidates: [{ + content: { + parts: [ + { text: 'internal thought', thought: true }, + { text: ' Gemini transcript ' } + ] + } + }] + }) + const result = await requestSpeechTranscription( + settingsWithSpeech({ + protocol: 'gemini-audio', + baseUrl: 'https://generativelanguage.googleapis.com/v1beta', + apiKey: 'gemini-key', + model: 'gemini-2.5-flash', + language: 'zh' + }), + { audioBase64: AUDIO_BASE64, mimeType: 'audio/wav' }, + { fetchImpl } + ) + + expect(result).toEqual({ ok: true, text: 'Gemini transcript' }) + expect(requests[0].url).toBe( + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent' + ) + const headers = requests[0].init.headers as Record + expect(headers['x-goog-api-key']).toBe('gemini-key') + const payload = JSON.parse(String(requests[0].init.body)) + expect(payload.contents[0].parts[0].text).toContain('expected language is zh') + expect(payload.contents[0].parts[1]).toEqual({ + inlineData: { + mimeType: 'audio/wav', + data: AUDIO_BASE64 + } + }) + }) + + it('uses Gemini CLI OAuth transcription without a base URL or API key', async () => { + const result = await requestSpeechTranscription( + settingsWithSpeech({ + providerId: 'gemini-cli-subscription', + protocol: 'gemini-cli-audio', + baseUrl: '', + apiKey: '', + model: 'gemini-2.5-flash' + }), + { audioBase64: AUDIO_BASE64, mimeType: 'audio/wav' }, + { + geminiCliTranscriber: async (speechToText, request) => { + expect(speechToText.model).toBe('gemini-2.5-flash') + expect(request.audioBase64).toBe(AUDIO_BASE64) + return ' Gemini CLI transcript ' + } + } + ) + + expect(result).toEqual({ ok: true, text: 'Gemini CLI transcript' }) + }) + it('transcribes local Whisper without requiring a base URL or API key', async () => { const result = await requestSpeechTranscription( settingsWithSpeech({ diff --git a/src/main/services/speech-to-text-service.ts b/src/main/services/speech-to-text-service.ts index 2a990c3ea..328b0e371 100644 --- a/src/main/services/speech-to-text-service.ts +++ b/src/main/services/speech-to-text-service.ts @@ -4,11 +4,20 @@ import { type KunSpeechToTextSettingsV1 } from '../../shared/app-settings' import { + isSpeechToTextConfigured, SPEECH_TRANSCRIPTION_MAX_BASE64_CHARS, type SpeechTranscriptionRequest, type SpeechTranscriptionResult } from '../../shared/speech-to-text' import { describeNetworkError } from '../../../kun/src/adapters/tool/image-gen-tool-provider.js' +import { + ensureFreshGrokCredentials, + resolveGrokMediaOAuthApiKey +} from '../grok-auth' +import { + speechTranscriptionPrompt, + transcribeViaGeminiCliAudio +} from './gemini-cli-speech-to-text-service' import { transcribeViaLocalWhisper } from './local-whisper-service' const FILE_EXTENSION_BY_MIME: Record = { @@ -21,19 +30,10 @@ const FILE_EXTENSION_BY_MIME: Record = { 'audio/flac': 'flac' } -export function isSpeechToTextConfigured( - speechToText: Pick -): boolean { - if (speechToText.protocol === 'local-whisper') { - return speechToText.enabled && Boolean(speechToText.model.trim()) - } - return ( - speechToText.enabled && - Boolean(speechToText.baseUrl.trim()) && - Boolean(speechToText.apiKey.trim()) && - Boolean(speechToText.model.trim()) - ) -} +const XAI_FORMAT_LANGUAGE_CODES = new Set([ + 'ar', 'cs', 'da', 'nl', 'en', 'fil', 'fr', 'de', 'hi', 'id', 'it', 'ja', 'ko', + 'mk', 'ms', 'fa', 'pl', 'pt', 'ro', 'ru', 'es', 'sv', 'th', 'tr', 'vi' +]) export async function requestSpeechTranscription( settings: AppSettingsV1, @@ -44,11 +44,15 @@ export async function requestSpeechTranscription( request: SpeechTranscriptionRequest, speechToText: KunSpeechToTextSettingsV1 ) => Promise + geminiCliTranscriber?: ( + speechToText: KunSpeechToTextSettingsV1, + request: SpeechTranscriptionRequest + ) => Promise } = {} ): Promise { const speechToText = request.speechToText ?? resolveKunSpeechToTextSettings(settings) if (!isSpeechToTextConfigured(speechToText)) { - return { ok: false, message: 'speech-to-text provider is not configured' } + return { ok: false, message: describeSpeechConfigurationIssue(speechToText) } } if (!request.audioBase64 || request.audioBase64.length > SPEECH_TRANSCRIPTION_MAX_BASE64_CHARS) { return { ok: false, message: 'audio payload is empty or too large' } @@ -56,11 +60,27 @@ export async function requestSpeechTranscription( const fetchImpl = options.fetchImpl ?? fetch try { - const text = speechToText.protocol === 'local-whisper' - ? await (options.localWhisperTranscriber ?? transcribeViaLocalWhisper)(request, speechToText) - : speechToText.protocol === 'mimo-asr' - ? await transcribeViaMimoAsr(speechToText, request, fetchImpl) - : await transcribeViaOpenAiTranscriptions(speechToText, request, fetchImpl) + let text: string + switch (speechToText.protocol) { + case 'local-whisper': + text = await (options.localWhisperTranscriber ?? transcribeViaLocalWhisper)(request, speechToText) + break + case 'mimo-asr': + text = await transcribeViaMimoAsr(speechToText, request, fetchImpl) + break + case 'xai-stt': + text = await transcribeViaXaiStt(speechToText, request, fetchImpl) + break + case 'gemini-audio': + text = await transcribeViaGeminiAudio(speechToText, request, fetchImpl) + break + case 'gemini-cli-audio': + text = await (options.geminiCliTranscriber ?? transcribeViaGeminiCliAudio)(speechToText, request) + break + default: + text = await transcribeViaOpenAiTranscriptions(speechToText, request, fetchImpl) + break + } const trimmed = text.trim() if (!trimmed) return { ok: false, message: 'transcription result is empty' } return { ok: true, text: trimmed } @@ -69,6 +89,27 @@ export async function requestSpeechTranscription( } } +function describeSpeechConfigurationIssue( + speechToText: Pick +): string { + if (!speechToText.enabled) return 'speech-to-text is disabled' + if ( + speechToText.protocol !== 'xai-stt' && + !speechToText.model.trim() + ) return 'speech-to-text model is not configured' + if ( + speechToText.protocol !== 'local-whisper' && + speechToText.protocol !== 'gemini-cli-audio' && + !speechToText.baseUrl.trim() + ) return 'speech-to-text API base URL is not configured' + if ( + speechToText.protocol !== 'local-whisper' && + speechToText.protocol !== 'gemini-cli-audio' && + !speechToText.apiKey.trim() + ) return 'speech-to-text API key is not configured' + return 'speech-to-text provider is not configured' +} + /** * Xiaomi MiMo ASR rides the OpenAI-compatible chat completions endpoint: * the audio goes in as a base64 data URI inside an `input_audio` content @@ -124,6 +165,102 @@ async function transcribeViaMimoAsr( throw new Error('speech response has no transcript content') } +/** + * xAI's dedicated batch STT API accepts multipart audio at /v1/stt. It does + * not take a chat model; the stored `grok-transcribe` id is a capability label. + */ +async function transcribeViaXaiStt( + speechToText: KunSpeechToTextSettingsV1, + request: SpeechTranscriptionRequest, + fetchImpl: typeof fetch +): Promise { + const fresh = await ensureFreshGrokCredentials(speechToText.apiKey) + const credential = resolveGrokMediaOAuthApiKey(fresh.apiKey) + const url = joinSpeechApiUrl(speechToText.baseUrl, 'stt') + const audio = Buffer.from(request.audioBase64, 'base64') + const form = new FormData() + const language = speechToText.language.trim().toLowerCase() + if (XAI_FORMAT_LANGUAGE_CODES.has(language)) { + form.append('format', 'true') + form.append('language', language) + } + const extension = FILE_EXTENSION_BY_MIME[request.mimeType.toLowerCase()] ?? 'wav' + // xAI requires the file field to be appended after all option fields. + form.append('file', new Blob([new Uint8Array(audio)], { type: request.mimeType }), `recording.${extension}`) + const response = await fetchImpl(url, { + method: 'POST', + headers: { + ...credential.headers, + Authorization: `Bearer ${credential.apiKey}` + }, + body: form, + signal: AbortSignal.timeout(speechToText.timeoutMs) + }) + const body = await response.text() + if (!response.ok) throw new SpeechHttpError(response.status, body) + const parsed = JSON.parse(body) as { text?: unknown } + if (typeof parsed.text !== 'string') throw new Error('xAI speech response has no transcript text') + return parsed.text +} + +/** + * Gemini's native GenerateContent API accepts small audio recordings inline. + * This path is for API-key providers; Gemini CLI subscription OAuth uses the + * separate Code Assist adapter above. + */ +async function transcribeViaGeminiAudio( + speechToText: KunSpeechToTextSettingsV1, + request: SpeechTranscriptionRequest, + fetchImpl: typeof fetch +): Promise { + const url = joinSpeechApiUrl( + speechToText.baseUrl, + `models/${encodeURIComponent(speechToText.model)}:generateContent` + ) + const response = await fetchImpl(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${speechToText.apiKey}`, + 'x-goog-api-key': speechToText.apiKey + }, + body: JSON.stringify({ + contents: [{ + role: 'user', + parts: [ + { text: speechTranscriptionPrompt(speechToText.language) }, + { + inlineData: { + mimeType: request.mimeType, + data: request.audioBase64 + } + } + ] + }], + generationConfig: { + temperature: 0, + maxOutputTokens: 2_048 + } + }), + signal: AbortSignal.timeout(speechToText.timeoutMs) + }) + const body = await response.text() + if (!response.ok) throw new SpeechHttpError(response.status, body) + const parsed = JSON.parse(body) as { + candidates?: Array<{ + content?: { + parts?: Array<{ text?: unknown; thought?: unknown }> + } + }> + } + const text = parsed.candidates?.[0]?.content?.parts + ?.filter((part) => part.thought !== true && typeof part.text === 'string') + .map((part) => part.text as string) + .join('') + if (typeof text !== 'string') throw new Error('Gemini speech response has no transcript text') + return text +} + /** Standard OpenAI-style multipart upload to {baseUrl}/audio/transcriptions. */ async function transcribeViaOpenAiTranscriptions( speechToText: KunSpeechToTextSettingsV1, diff --git a/src/main/services/workspace-files.test.ts b/src/main/services/workspace-files.test.ts new file mode 100644 index 000000000..0451b67ff --- /dev/null +++ b/src/main/services/workspace-files.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, rm, utimes, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + decodeWorkspaceTextPreview, + listWorkspaceDirectory, + readWorkspaceFile, + writeWorkspaceFile +} from './workspace-files' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +async function createWorkspace(): Promise { + const path = await mkdtemp(join(tmpdir(), 'kun-workspace-files-')) + temporaryDirectories.push(path) + return path +} + +describe('workspace text preview decoding', () => { + it('decodes UTF-8 and strips its BOM', () => { + expect(decodeWorkspaceTextPreview(Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from('hello 世界', 'utf8') + ]))).toBe('hello 世界') + }) + + it('decodes UTF-16 little-endian and big-endian BOM files', () => { + const source = '工作表 A1' + const littleEndian = Buffer.concat([ + Buffer.from([0xff, 0xfe]), + Buffer.from(source, 'utf16le') + ]) + const bigEndianBody = Buffer.from(source, 'utf16le') + for (let index = 0; index + 1 < bigEndianBody.length; index += 2) { + const first = bigEndianBody[index] + bigEndianBody[index] = bigEndianBody[index + 1] + bigEndianBody[index + 1] = first + } + const bigEndian = Buffer.concat([Buffer.from([0xfe, 0xff]), bigEndianBody]) + + expect(decodeWorkspaceTextPreview(littleEndian)).toBe(source) + expect(decodeWorkspaceTextPreview(bigEndian)).toBe(source) + }) + + it('keeps unknown NUL-containing binary files out of the text preview path', () => { + expect(decodeWorkspaceTextPreview(Buffer.from([0x50, 0x4b, 0x03, 0x04, 0, 1]))).toBeNull() + }) +}) + +describe('workspace file metadata and conflict-aware writes', () => { + it('returns size and modification metadata without failing for directories', async () => { + const workspaceRoot = await createWorkspace() + await writeFile(join(workspaceRoot, 'note.md'), 'hello', 'utf8') + await mkdir(join(workspaceRoot, 'docs')) + + const result = await listWorkspaceDirectory({ workspaceRoot }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.entries).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'note.md', type: 'file', size: 5, mtimeMs: expect.any(Number) }), + expect.objectContaining({ name: 'docs', type: 'directory', mtimeMs: expect.any(Number) }) + ])) + }) + + it('rejects stale writes and permits an explicit overwrite', async () => { + const workspaceRoot = await createWorkspace() + const path = join(workspaceRoot, 'note.md') + await writeFile(path, 'first', 'utf8') + const read = await readWorkspaceFile({ workspaceRoot, path: 'note.md' }) + expect(read.ok).toBe(true) + if (!read.ok) return + + await writeFile(path, 'outside', 'utf8') + const future = new Date(Date.now() + 2_000) + await utimes(path, future, future) + + const conflict = await writeWorkspaceFile({ + workspaceRoot, + path: 'note.md', + content: 'editor', + expectedMtimeMs: read.mtimeMs + }) + expect(conflict).toEqual(expect.objectContaining({ + ok: false, + code: 'modified_on_disk', + mtimeMs: expect.any(Number) + })) + + const forced = await writeWorkspaceFile({ + workspaceRoot, + path: 'note.md', + content: 'editor', + expectedMtimeMs: read.mtimeMs, + force: true + }) + expect(forced).toEqual(expect.objectContaining({ ok: true, mtimeMs: expect.any(Number) })) + }) +}) diff --git a/src/main/services/workspace-files.ts b/src/main/services/workspace-files.ts index 6c42514da..c100022dc 100644 --- a/src/main/services/workspace-files.ts +++ b/src/main/services/workspace-files.ts @@ -71,21 +71,50 @@ const WORKSPACE_IMAGE_MIME_BY_EXT = new Map([ ['.ico', 'image/x-icon'] ]) +export function decodeWorkspaceTextPreview(bytes: Buffer): string | null { + if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { + return bytes.subarray(3).toString('utf8') + } + + if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) { + const body = bytes.subarray(2, bytes.length - ((bytes.length - 2) % 2)) + return body.toString('utf16le') + } + + if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) { + const body = Buffer.from(bytes.subarray(2, bytes.length - ((bytes.length - 2) % 2))) + for (let index = 0; index + 1 < body.length; index += 2) { + const first = body[index] + body[index] = body[index + 1] + body[index + 1] = first + } + return body.toString('utf16le') + } + + return bytes.includes(0) ? null : bytes.toString('utf8') +} + export async function listWorkspaceDirectory( payload: WorkspaceDirectoryTarget ): Promise { try { const root = await resolveWorkspaceDirectory(payload) const entries = await readdir(root, { withFileTypes: true }) - const normalized = entries + const normalized = await Promise.all(entries .filter((entry) => entry.name !== '.DS_Store') - .map((entry) => ({ + .map(async (entry) => { + const entryPath = join(root, entry.name) + const metadata = await stat(entryPath).catch(() => null) + return { name: entry.name, - path: join(root, entry.name), + path: entryPath, type: entry.isDirectory() ? ('directory' as const) : ('file' as const), - ext: entry.isDirectory() ? '' : extensionFromName(entry.name) + ext: entry.isDirectory() ? '' : extensionFromName(entry.name), + ...(metadata ? { mtimeMs: metadata.mtimeMs } : {}), + ...(metadata?.isFile() ? { size: metadata.size } : {}) + } })) - .sort(compareWorkspaceEntries) + normalized.sort(compareWorkspaceEntries) return { ok: true, root, entries: normalized } } catch (error) { @@ -110,15 +139,17 @@ export async function readWorkspaceFile(payload: WorkspaceFileTarget): Promise MAX_FILE_PREVIEW_BYTES, ...(payload.line ? { line: payload.line } : {}), ...(payload.column ? { column: payload.column } : {}) @@ -213,6 +244,17 @@ export async function writeWorkspaceFile( // previous version intact rather than producing a half-written file. try { const targetPath = await resolveTargetPathWithinWorkspace(payload.path, payload.workspaceRoot) + if (payload.expectedMtimeMs !== undefined && payload.force !== true) { + const current = await stat(targetPath).catch(() => null) + if (!current || current.mtimeMs !== payload.expectedMtimeMs) { + return { + ok: false, + code: 'modified_on_disk', + message: 'This file changed on disk after it was opened.', + ...(current ? { mtimeMs: current.mtimeMs } : {}) + } + } + } await mkdir(dirname(targetPath), { recursive: true }) const tmpPath = `${targetPath}.${randomUUID()}.tmp` try { @@ -223,10 +265,12 @@ export async function writeWorkspaceFile( await unlink(tmpPath).catch(() => undefined) throw writeError } + const saved = await stat(targetPath) return { ok: true, path: targetPath, - savedAt: new Date().toISOString() + savedAt: new Date().toISOString(), + mtimeMs: saved.mtimeMs } } catch (error) { return { diff --git a/src/main/services/workspace-preview-protocol.test.ts b/src/main/services/workspace-preview-protocol.test.ts new file mode 100644 index 000000000..cd933b98f --- /dev/null +++ b/src/main/services/workspace-preview-protocol.test.ts @@ -0,0 +1,100 @@ +import { EventEmitter } from 'node:events' +import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + KUN_WORKSPACE_PREVIEW_SCHEME, + WorkspacePreviewProtocolRegistry, + parseWorkspaceByteRange, + parseWorkspacePreviewUrl, + sanitizeStaticHtml +} from './workspace-preview-protocol' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +async function createTemporaryDirectory(prefix: string): Promise { + const path = await mkdtemp(join(tmpdir(), prefix)) + temporaryDirectories.push(path) + return path +} + +function createSender(id = 42): EventEmitter & { id: number } { + return Object.assign(new EventEmitter(), { id }) +} + +describe('workspace preview protocol', () => { + it('creates opaque leases and serves byte ranges', async () => { + const workspaceRoot = await createTemporaryDirectory('kun-workspace-preview-') + await writeFile(join(workspaceRoot, 'clip.mp4'), Buffer.from('0123456789')) + let handler: ((request: Request) => Promise) | undefined + const protocol = { + unhandle: vi.fn(), + handle: vi.fn((_scheme: string, next: (request: Request) => Promise) => { + handler = next + }) + } + const registry = new WorkspacePreviewProtocolRegistry({ + randomToken: () => 'abcdefghijklmnopqrstuvwxyzABCDEFGH123456789' + }) + registry.register(protocol as never) + + const lease = await registry.createLease( + createSender() as never, + { workspaceRoot, path: 'clip.mp4' } + ) + expect(lease).toEqual(expect.objectContaining({ + ok: true, + url: expect.stringMatching(/^kun-workspace-preview:\/\/lease\//), + mimeType: 'video/mp4' + })) + if (!lease.ok || !handler) return + + const response = await handler(new Request(lease.url, { headers: { Range: 'bytes=2-5' } })) + expect(response.status).toBe(206) + expect(response.headers.get('content-range')).toBe('bytes 2-5/10') + expect(Buffer.from(await response.arrayBuffer()).toString()).toBe('2345') + }) + + it('blocks symlink escapes and releases leases when the sender is destroyed', async () => { + const workspaceRoot = await createTemporaryDirectory('kun-workspace-preview-root-') + const outsideRoot = await createTemporaryDirectory('kun-workspace-preview-outside-') + await writeFile(join(outsideRoot, 'outside.mp3'), 'secret') + await symlink(join(outsideRoot, 'outside.mp3'), join(workspaceRoot, 'outside.mp3')) + const registry = new WorkspacePreviewProtocolRegistry() + const sender = createSender() + + const escaped = await registry.createLease(sender as never, { + workspaceRoot, + path: 'outside.mp3' + }) + expect(escaped).toEqual(expect.objectContaining({ ok: false })) + + await writeFile(join(workspaceRoot, 'inside.mp3'), 'audio') + const valid = await registry.createLease(sender as never, { + workspaceRoot, + path: 'inside.mp3' + }) + expect(valid.ok).toBe(true) + if (!valid.ok) return + sender.emit('destroyed') + expect(registry.release(sender.id, valid.leaseId)).toEqual({ + ok: false, + message: 'Preview resource lease is unavailable.' + }) + }) + + it('sanitizes active HTML and rejects malformed URLs and ranges', () => { + expect(sanitizeStaticHtml( + 'a' + )).toBe('a') + expect(() => parseWorkspacePreviewUrl(`${KUN_WORKSPACE_PREVIEW_SCHEME}://other/token/file.png`)) + .toThrow() + expect(parseWorkspaceByteRange('bytes=-4', 10)).toEqual({ start: 6, end: 9, length: 4 }) + expect(() => parseWorkspaceByteRange('bytes=20-30', 10)).toThrow() + }) +}) diff --git a/src/main/services/workspace-preview-protocol.ts b/src/main/services/workspace-preview-protocol.ts new file mode 100644 index 000000000..2cdbdadac --- /dev/null +++ b/src/main/services/workspace-preview-protocol.ts @@ -0,0 +1,402 @@ +import { randomBytes } from 'node:crypto' +import { open, readFile, realpath, stat, type FileHandle } from 'node:fs/promises' +import { extname, isAbsolute, relative, resolve } from 'node:path' +import { Readable } from 'node:stream' +import type { Protocol, WebContents } from 'electron' +import type { + WorkspacePreviewLeaseReleaseResult, + WorkspacePreviewLeaseResult, + WorkspacePreviewLeaseTarget +} from '../../shared/workspace-file' +import { resolveOpenTargetPath } from './workspace-paths' + +export const KUN_WORKSPACE_PREVIEW_SCHEME = 'kun-workspace-preview' + +export const KUN_WORKSPACE_PREVIEW_PRIVILEGED_SCHEME = { + scheme: KUN_WORKSPACE_PREVIEW_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: false, + bypassCSP: false, + stream: true + } +} as const + +const DEFAULT_LEASE_TTL_MS = 5 * 60 * 1_000 +const MAX_RESOURCE_BYTES = 512 * 1024 * 1024 +const MAX_RANGE_BYTES = 256 * 1024 * 1024 +const LEASE_TOKEN = /^[A-Za-z0-9_-]{32,128}$/ +const STATIC_HTML_CSP = [ + "default-src 'none'", + "script-src 'none'", + "style-src 'self' 'unsafe-inline' kun-workspace-preview:", + "img-src 'self' data: blob: kun-workspace-preview:", + "font-src 'self' data: kun-workspace-preview:", + "media-src 'self' kun-workspace-preview:", + "connect-src 'none'", + "frame-src 'none'", + "object-src 'none'", + "base-uri 'none'", + "form-action 'none'" +].join('; ') + +const MIME_BY_EXTENSION = new Map([ + ['.html', 'text/html; charset=utf-8'], + ['.htm', 'text/html; charset=utf-8'], + ['.css', 'text/css; charset=utf-8'], + ['.png', 'image/png'], + ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], + ['.gif', 'image/gif'], + ['.webp', 'image/webp'], + ['.svg', 'image/svg+xml'], + ['.bmp', 'image/bmp'], + ['.avif', 'image/avif'], + ['.ico', 'image/x-icon'], + ['.woff', 'font/woff'], + ['.woff2', 'font/woff2'], + ['.ttf', 'font/ttf'], + ['.otf', 'font/otf'], + ['.mp3', 'audio/mpeg'], + ['.wav', 'audio/wav'], + ['.ogg', 'audio/ogg'], + ['.oga', 'audio/ogg'], + ['.m4a', 'audio/mp4'], + ['.aac', 'audio/aac'], + ['.flac', 'audio/flac'], + ['.mp4', 'video/mp4'], + ['.m4v', 'video/mp4'], + ['.webm', 'video/webm'], + ['.ogv', 'video/ogg'], + ['.mov', 'video/quicktime'] +]) + +type ProtocolHandler = Pick + +type ActiveLease = { + leaseId: string + senderId: number + workspaceRoot: string + entryRelativePath: string + expiresAt: number + timer: ReturnType +} + +type ParsedRange = { + start: number + end: number + length: number +} + +export class WorkspacePreviewProtocolRegistry { + private readonly leases = new Map() + private readonly leaseIdsBySender = new Map>() + private readonly boundSenders = new Map void>() + + constructor( + private readonly options: { + now?: () => number + randomToken?: () => string + leaseTtlMs?: number + } = {} + ) {} + + register(protocol: ProtocolHandler): void { + try { + protocol.unhandle(KUN_WORKSPACE_PREVIEW_SCHEME) + } catch { + // First registration has no existing handler. + } + protocol.handle(KUN_WORKSPACE_PREVIEW_SCHEME, (request) => this.handleRequest(request)) + } + + async createLease( + sender: WebContents, + target: WorkspacePreviewLeaseTarget + ): Promise { + try { + const workspaceRoot = await realpath(resolve(target.workspaceRoot)) + const targetPath = await resolveOpenTargetPath(target.path, workspaceRoot, { + allowBasenameFallback: false + }) + const canonicalTarget = await realpath(targetPath) + if (!isPathWithin(workspaceRoot, canonicalTarget)) { + return { ok: false, message: 'Preview resources must stay within the workspace.' } + } + const metadata = await stat(canonicalTarget) + if (!metadata.isFile()) return { ok: false, message: 'Cannot preview a directory.' } + if (metadata.size > MAX_RESOURCE_BYTES) { + return { ok: false, message: 'This resource is too large to preview.' } + } + const mimeType = mimeTypeForPath(canonicalTarget) + if (!mimeType) return { ok: false, message: 'This resource type is not supported.' } + + const leaseId = this.createLeaseId() + const now = this.options.now?.() ?? Date.now() + const expiresAt = now + (this.options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS) + const timer = setTimeout(() => this.releaseLease(leaseId), Math.max(1, expiresAt - now)) + timer.unref?.() + const entryRelativePath = normalizeRelativePath(relative(workspaceRoot, canonicalTarget)) + const lease: ActiveLease = { + leaseId, + senderId: sender.id, + workspaceRoot, + entryRelativePath, + expiresAt, + timer + } + this.leases.set(leaseId, lease) + const senderLeases = this.leaseIdsBySender.get(sender.id) ?? new Set() + senderLeases.add(leaseId) + this.leaseIdsBySender.set(sender.id, senderLeases) + this.bindSender(sender) + return { + ok: true, + leaseId, + url: buildWorkspacePreviewUrl(leaseId, entryRelativePath), + mimeType, + size: metadata.size, + mtimeMs: metadata.mtimeMs, + expiresAt: new Date(expiresAt).toISOString() + } + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error) + } + } + } + + release(senderId: number, leaseId: string): WorkspacePreviewLeaseReleaseResult { + const lease = this.leases.get(leaseId) + if (!lease || lease.senderId !== senderId) { + return { ok: false, message: 'Preview resource lease is unavailable.' } + } + this.releaseLease(leaseId) + return { ok: true } + } + + releaseForSender(senderId: number): void { + const leaseIds = [...(this.leaseIdsBySender.get(senderId) ?? [])] + for (const leaseId of leaseIds) this.releaseLease(leaseId) + this.leaseIdsBySender.delete(senderId) + this.boundSenders.delete(senderId) + } + + dispose(): void { + for (const leaseId of [...this.leases.keys()]) this.releaseLease(leaseId) + this.boundSenders.clear() + } + + private bindSender(sender: WebContents): void { + if (this.boundSenders.has(sender.id)) return + const onDestroyed = (): void => this.releaseForSender(sender.id) + this.boundSenders.set(sender.id, onDestroyed) + sender.once('destroyed', onDestroyed) + } + + private async handleRequest(request: Request): Promise { + try { + if (request.method !== 'GET' && request.method !== 'HEAD') { + return workspacePreviewError('Method not allowed.', 405) + } + const parsed = parseWorkspacePreviewUrl(request.url) + const lease = this.leases.get(parsed.leaseId) + const now = this.options.now?.() ?? Date.now() + if (!lease || now >= lease.expiresAt) { + if (lease) this.releaseLease(lease.leaseId) + return workspacePreviewError('Preview resource unavailable.', 404) + } + const candidate = await realpath(resolve(lease.workspaceRoot, parsed.relativePath)) + if (!isPathWithin(lease.workspaceRoot, candidate)) { + return workspacePreviewError('Preview resource unavailable.', 404) + } + const metadata = await stat(candidate) + if (!metadata.isFile() || metadata.size > MAX_RESOURCE_BYTES) { + return workspacePreviewError('Preview resource unavailable.', 404) + } + const mimeType = mimeTypeForPath(candidate) + if (!mimeType) return workspacePreviewError('Preview resource unavailable.', 404) + if (request.method === 'HEAD') { + return new Response(null, { + status: 200, + headers: workspacePreviewHeaders(mimeType, metadata.size) + }) + } + if (mimeType.startsWith('text/html')) { + const html = sanitizeStaticHtml(await readFile(candidate, 'utf8')) + return new Response(html, { + status: 200, + headers: workspacePreviewHeaders(mimeType, Buffer.byteLength(html)) + }) + } + return await streamWorkspaceResource(candidate, mimeType, metadata.size, request) + } catch { + return workspacePreviewError('Preview resource unavailable.', 404) + } + } + + private createLeaseId(): string { + for (let attempt = 0; attempt < 8; attempt += 1) { + const candidate = this.options.randomToken?.() ?? randomBytes(32).toString('base64url') + if (LEASE_TOKEN.test(candidate) && !this.leases.has(candidate)) return candidate + } + throw new Error('Could not create a preview resource lease.') + } + + private releaseLease(leaseId: string): void { + const lease = this.leases.get(leaseId) + if (!lease) return + clearTimeout(lease.timer) + this.leases.delete(leaseId) + const senderLeases = this.leaseIdsBySender.get(lease.senderId) + senderLeases?.delete(leaseId) + if (senderLeases?.size === 0) this.leaseIdsBySender.delete(lease.senderId) + } +} + +export function buildWorkspacePreviewUrl(leaseId: string, relativePath: string): string { + const encodedPath = normalizeRelativePath(relativePath) + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/') + return `${KUN_WORKSPACE_PREVIEW_SCHEME}://lease/${leaseId}/${encodedPath}` +} + +export function parseWorkspacePreviewUrl(rawUrl: string): { + leaseId: string + relativePath: string +} { + const url = new URL(rawUrl) + if ( + url.protocol !== `${KUN_WORKSPACE_PREVIEW_SCHEME}:` || + url.hostname !== 'lease' || + url.username || + url.password || + url.port || + url.search || + url.hash + ) { + throw new Error('Invalid preview resource URL.') + } + const segments = url.pathname.split('/').filter(Boolean).map((segment) => decodeURIComponent(segment)) + const leaseId = segments.shift() ?? '' + if (!LEASE_TOKEN.test(leaseId) || segments.length === 0) { + throw new Error('Invalid preview resource URL.') + } + const relativePath = normalizeRelativePath(segments.join('/')) + if (!relativePath || relativePath.split('/').some((segment) => segment === '.' || segment === '..')) { + throw new Error('Invalid preview resource URL.') + } + return { leaseId, relativePath } +} + +export function sanitizeStaticHtml(html: string): string { + const withoutScripts = html.replace(/]*>[\s\S]*?<\/script\s*>/gi, '') + return withoutScripts + .replace(/\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '') + .replace(/\s(?:src|href)\s*=\s*(["'])\s*(?:javascript|data:text\/html)[^"']*\1/gi, '') +} + +export function parseWorkspaceByteRange( + value: string, + resourceSize: number, + maxRangeBytes = MAX_RANGE_BYTES +): ParsedRange { + const match = /^bytes=(\d*)-(\d*)$/i.exec(value.trim()) + if (!match || resourceSize <= 0) throw new Error('Invalid byte range.') + let start: number + let end: number + if (!match[1]) { + const suffixLength = Number(match[2]) + if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) throw new Error('Invalid byte range.') + start = Math.max(0, resourceSize - suffixLength) + end = resourceSize - 1 + } else { + start = Number(match[1]) + end = match[2] ? Number(match[2]) : resourceSize - 1 + } + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start < 0 || + end < start || + start >= resourceSize + ) { + throw new Error('Invalid byte range.') + } + end = Math.min(end, resourceSize - 1) + if (end - start + 1 > maxRangeBytes) end = start + maxRangeBytes - 1 + return { start, end, length: end - start + 1 } +} + +async function streamWorkspaceResource( + path: string, + mimeType: string, + resourceSize: number, + request: Request +): Promise { + let file: FileHandle | undefined + try { + file = await open(path, 'r') + if (resourceSize === 0) { + await file.close() + return new Response(null, { status: 200, headers: workspacePreviewHeaders(mimeType, 0) }) + } + const rangeHeader = request.headers.get('range') + const range = rangeHeader ? parseWorkspaceByteRange(rangeHeader, resourceSize) : undefined + const start = range?.start ?? 0 + const end = range?.end ?? resourceSize - 1 + const stream = file.createReadStream({ autoClose: true, start, end, highWaterMark: 64 * 1024 }) + file = undefined + if (request.signal.aborted) stream.destroy() + else request.signal.addEventListener('abort', () => stream.destroy(), { once: true }) + const headers = workspacePreviewHeaders(mimeType, range?.length ?? resourceSize) + if (range) headers['Content-Range'] = `bytes ${range.start}-${range.end}/${resourceSize}` + return new Response(Readable.toWeb(stream) as ReadableStream, { + status: range ? 206 : 200, + headers + }) + } catch (error) { + await file?.close().catch(() => undefined) + throw error + } +} + +function workspacePreviewHeaders(mimeType: string, contentLength: number): Record { + return { + 'Content-Type': mimeType, + 'Content-Length': String(contentLength), + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'no-store', + 'Content-Security-Policy': STATIC_HTML_CSP, + 'X-Content-Type-Options': 'nosniff' + } +} + +function workspacePreviewError(message: string, status: number): Response { + return new Response(message, { + status, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + 'Content-Security-Policy': STATIC_HTML_CSP, + 'X-Content-Type-Options': 'nosniff' + } + }) +} + +function mimeTypeForPath(path: string): string | null { + return MIME_BY_EXTENSION.get(extname(path).toLowerCase()) ?? null +} + +function normalizeRelativePath(path: string): string { + return path.replaceAll('\\', '/').replace(/^\/+/, '') +} + +function isPathWithin(root: string, target: string): boolean { + const rel = relative(root, target) + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) +} diff --git a/src/main/services/write-inline-completion-service.test.ts b/src/main/services/write-inline-completion-service.test.ts index 0f22b27f7..bead93d41 100644 --- a/src/main/services/write-inline-completion-service.test.ts +++ b/src/main/services/write-inline-completion-service.test.ts @@ -45,7 +45,7 @@ function createSettings(patch: Partial { expect(result.pages[0]?.text).toContain('Local PDF attachment text') }, 15_000) + it('rejects a renamed non-PDF before parsing', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'ds-gui-local-pdf-spoof-')) + const pdfPath = join(workspaceRoot, 'not-really.pdf') + await writeFile(pdfPath, 'plain text with a PDF extension') + + await expect(readLocalPdfText({ path: pdfPath })).resolves.toEqual({ + ok: false, + message: 'File content does not match the PDF format.' + }) + }) + it('falls back to OCR for image-only local PDF attachments', async () => { const workspaceRoot = await mkdtemp(join(tmpdir(), 'ds-gui-local-pdf-ocr-')) const pdfPath = join(workspaceRoot, 'scanned.pdf') diff --git a/src/main/services/write-pdf-text-service.ts b/src/main/services/write-pdf-text-service.ts index 4245f0fbf..0fdc424a4 100644 --- a/src/main/services/write-pdf-text-service.ts +++ b/src/main/services/write-pdf-text-service.ts @@ -1,5 +1,5 @@ import { createRequire } from 'node:module' -import { readFile, stat } from 'node:fs/promises' +import { open, readFile, stat } from 'node:fs/promises' import { extname } from 'node:path' import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist/legacy/build/pdf.mjs' import type { WorkspaceFileTarget } from '../../shared/workspace-file' @@ -356,6 +356,16 @@ async function readLocalPdfTextByPath(targetPath: string, tooLargeMessage: strin if (extname(targetPath).toLowerCase() !== '.pdf') { return { ok: false, message: 'This file is not a PDF document.' } } + const header = Buffer.alloc(5) + const handle = await open(targetPath, 'r') + try { + const { bytesRead } = await handle.read(header, 0, header.length, 0) + if (bytesRead !== header.length || header.toString('ascii') !== '%PDF-') { + return { ok: false, message: 'File content does not match the PDF format.' } + } + } finally { + await handle.close() + } const cacheKey = `${targetPath}:${fileInfo.size}:${fileInfo.mtimeMs}` const cached = pdfTextCache.get(cacheKey) diff --git a/src/main/settings-store.test.ts b/src/main/settings-store.test.ts index f3bfab593..5a98cf4fd 100644 --- a/src/main/settings-store.test.ts +++ b/src/main/settings-store.test.ts @@ -6,6 +6,7 @@ import { DEFAULT_APPROVAL_POLICY, DEFAULT_CHECKPOINT_CLEANUP_ENABLED, DEFAULT_CHECKPOINT_CLEANUP_INTERVAL_DAYS, + DEFAULT_GIT_CHECKPOINT_CREATE_ENABLED, defaultKunRuntimeSettings, defaultModelProviderSettings } from '../shared/app-settings' @@ -44,6 +45,7 @@ describe('JsonSettingsStore', () => { expect(loaded.checkpointCleanup.intervalDays).toBe(DEFAULT_CHECKPOINT_CLEANUP_INTERVAL_DAYS) // Checkpoint cleanup is enabled by default to keep stale checkpoints from accumulating. expect(loaded.checkpointCleanup.enabled).toBe(DEFAULT_CHECKPOINT_CLEANUP_ENABLED) + expect(loaded.checkpointCleanup.createEnabled).toBe(DEFAULT_GIT_CHECKPOINT_CREATE_ENABLED) expect(loaded.appBehavior).toEqual({ openAtLogin: false, startMinimized: false, diff --git a/src/main/settings-store.ts b/src/main/settings-store.ts index f836c0dc3..c7678d42f 100644 --- a/src/main/settings-store.ts +++ b/src/main/settings-store.ts @@ -7,6 +7,7 @@ import { DEFAULT_GUI_UPDATE_CHANNEL, DEFAULT_CHECKPOINT_CLEANUP_ENABLED, DEFAULT_CHECKPOINT_CLEANUP_INTERVAL_DAYS, + DEFAULT_GIT_CHECKPOINT_CREATE_ENABLED, DEFAULT_CURSOR_SPOTLIGHT_COLOR, DEFAULT_GIT_BRANCH_PREFIX, DEFAULT_LOG_RETENTION_DAYS, @@ -225,6 +226,7 @@ async function ensureManagedWorkspaceRootsExist(settings: AppSettingsV1): Promis const defaultSettings = (): AppSettingsV1 => ({ version: 1, + initialSetupCompleted: false, locale: 'en', theme: 'system', uiFontScale: DEFAULT_UI_FONT_SCALE, @@ -242,6 +244,7 @@ const defaultSettings = (): AppSettingsV1 => ({ retentionDays: DEFAULT_LOG_RETENTION_DAYS }, checkpointCleanup: { + createEnabled: DEFAULT_GIT_CHECKPOINT_CREATE_ENABLED, enabled: DEFAULT_CHECKPOINT_CLEANUP_ENABLED, intervalDays: DEFAULT_CHECKPOINT_CLEANUP_INTERVAL_DAYS }, diff --git a/src/main/upstream-models.test.ts b/src/main/upstream-models.test.ts index c0047b53b..4247d1ce9 100644 --- a/src/main/upstream-models.test.ts +++ b/src/main/upstream-models.test.ts @@ -53,7 +53,7 @@ function settings(dataDir: string, model = 'settings-model'): AppSettingsV1 { workspaceRoot: '/tmp/workspace', conversationWorkspaceRoot: '~/Documents/Kun', log: { enabled: false, retentionDays: 7 }, - checkpointCleanup: { enabled: false, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, notifications: { turnComplete: true }, appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, keyboardShortcuts: defaultKeyboardShortcuts(), @@ -130,6 +130,10 @@ describe('upstream model picker list', () => { expect(result.modelIds).toContain('deepseek-chat') expect(result.modelIds).not.toContain('auto') expect(result.defaultModelId).toBe('local-only-model') + expect(result.defaultModel).toEqual({ + providerId: 'custom-provider', + modelId: 'local-only-model' + }) expect(result.modelGroups).toEqual(expect.arrayContaining([ expect.objectContaining({ providerId: 'custom-provider', @@ -258,6 +262,29 @@ describe('upstream model picker list', () => { } }) + it('keeps the configured provider on a default model id shared by multiple providers', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'deepseek-gui-models-')) + await mkdir(dataDir, { recursive: true }) + const configured = settings(dataDir, 'shared-model') + configured.provider.providers = configured.provider.providers.map((provider) => + provider.id === 'deepseek' + ? { ...provider, models: [...provider.models, 'shared-model'] } + : provider.id === 'custom-provider' + ? { ...provider, models: [...provider.models, 'shared-model'] } + : provider + ) + + const result = await fetchUpstreamModelIds(configured) + + expect(result).toMatchObject({ + ok: true, + defaultModel: { + providerId: 'custom-provider', + modelId: 'shared-model' + } + }) + }) + it('never queries the upstream /v1/models catalog for the composer picker (issue #337)', async () => { const dataDir = mkdtempSync(join(tmpdir(), 'deepseek-gui-models-')) await mkdir(dataDir, { recursive: true }) diff --git a/src/main/upstream-models.ts b/src/main/upstream-models.ts index 82408212d..c4ae17ab5 100644 --- a/src/main/upstream-models.ts +++ b/src/main/upstream-models.ts @@ -3,11 +3,11 @@ import { homedir } from 'node:os' import { join } from 'node:path' import { getModelProviderSettings, + getModelProviderProfile, isComposerChatModelId, + isProviderComposerChatModelId, listModelProviderModelIds, listNonTextModelIds, - listProviderNonTextModelIds, - modelProfileSupportsTextChat, modelProviderModelProfile, projectExecutableModelRoutePools, resolveKunRuntimeSettings, @@ -15,10 +15,20 @@ import { type ModelProviderModelProfileV1 } from '../shared/app-settings' import { DEFAULT_COMPOSER_MODEL_IDS } from '../shared/default-composer-models' -import type { ModelProviderModelGroup } from '../shared/kun-gui-api' +import type { + ModelProviderModelGroup, + ModelProviderModelSelection +} from '../shared/kun-gui-api' export type FetchUpstreamModelsResult = - | { ok: true; modelIds: string[]; defaultModelId?: string; modelGroups?: ModelProviderModelGroup[] } + | { + ok: true + modelIds: string[] + /** @deprecated Use defaultModel so the provider binding is not ambiguous. */ + defaultModelId?: string + defaultModel?: ModelProviderModelSelection + modelGroups?: ModelProviderModelGroup[] + } | { ok: false; message: string } export function fallbackModelIds(): string[] { @@ -47,18 +57,16 @@ export async function fetchUpstreamModelIds( ): Promise { const configuredModelIds = await readConfiguredKunModelIds(settings) const configuredGroups = await readConfiguredModelGroups(settings) - const providerSettings = getModelProviderSettings(settings) const runtime = resolveKunRuntimeSettings(settings) const runtimeModel = runtime.model.trim() - const runtimeProvider = providerSettings.providers.find((provider) => provider.id === runtime.providerId) - const runtimeNonTextModelIds = runtimeProvider - ? listProviderNonTextModelIds(runtimeProvider) - : listNonTextModelIds(settings) - const defaultModelId = isComposerChatModelId(runtimeModel, runtimeNonTextModelIds) ? runtimeModel : '' + const runtimeProvider = getModelProviderProfile(settings, runtime.providerId) + const defaultModel = isProviderComposerChatModelId(runtimeProvider, runtimeModel) + ? { providerId: runtimeProvider.id, modelId: runtimeModel } + : undefined return modelListOrError( configuredModelIds, configuredGroups, - defaultModelId, + defaultModel, 'Configured providers have no usable text models yet.' ) } @@ -90,22 +98,25 @@ export async function readConfiguredKunModelIds(settings: AppSettingsV1): Promis function modelListOrError( ids: readonly string[], groups: readonly ModelProviderModelGroup[], - defaultModelId: string, + defaultModel: ModelProviderModelSelection | undefined, message: string ): FetchUpstreamModelsResult { return hasCustomModelId(ids) - ? { ok: true, modelIds: mergeModelIds(ids), defaultModelId, modelGroups: mergeModelGroups(groups) } + ? { + ok: true, + modelIds: mergeModelIds(ids), + ...(defaultModel + ? { defaultModelId: defaultModel.modelId, defaultModel } + : {}), + modelGroups: mergeModelGroups(groups) + } : { ok: false, message } } async function readConfiguredModelGroups(settings: AppSettingsV1): Promise { const groups: ModelProviderModelGroup[] = [] for (const provider of getModelProviderSettings(settings).providers) { - const nonTextModelIds = listProviderNonTextModelIds(provider) - const modelIds = provider.models.filter((id) => - isComposerChatModelId(id, nonTextModelIds) - && modelProfileSupportsTextChat(modelProviderModelProfile(provider, id)) - ) + const modelIds = provider.models.filter((id) => isProviderComposerChatModelId(provider, id)) if (modelIds.length === 0) continue groups.push({ providerId: provider.id, diff --git a/src/main/workflow-runtime.nodes.test.ts b/src/main/workflow-runtime.nodes.test.ts index 39fc250f7..0b53c6e00 100644 --- a/src/main/workflow-runtime.nodes.test.ts +++ b/src/main/workflow-runtime.nodes.test.ts @@ -127,7 +127,7 @@ function buildSettings( workspaceRoot: workflowWorkspaceRoot, conversationWorkspaceRoot: '~/Documents/Kun', log: { enabled: true, retentionDays: 7 }, - checkpointCleanup: { enabled: false, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, notifications: { turnComplete: true }, appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, keyboardShortcuts: defaultKeyboardShortcuts(), diff --git a/src/main/workflow-runtime.run.test.ts b/src/main/workflow-runtime.run.test.ts index c57c8906f..415cbc64d 100644 --- a/src/main/workflow-runtime.run.test.ts +++ b/src/main/workflow-runtime.run.test.ts @@ -59,7 +59,7 @@ function settingsWithWorkflows(workflows: WorkflowV1[], modules: WorkflowCustomM workspaceRoot: workflowWorkspaceRoot, conversationWorkspaceRoot: '~/Documents/Kun', log: { enabled: true, retentionDays: 7 }, - checkpointCleanup: { enabled: false, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, notifications: { turnComplete: true }, appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, keyboardShortcuts: defaultKeyboardShortcuts(), diff --git a/src/preload/index.ts b/src/preload/index.ts index 49840c6f6..7e533cbcc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -59,8 +59,10 @@ const api = { respondRendererRequest: (response) => ipcRenderer.invoke('data-migration:renderer-response', response) }, getSettings: () => ipcRenderer.invoke('settings:get'), + resetUnreadableCredentials: () => ipcRenderer.invoke('credentials:reset-unreadable'), claudeSubscriptionStatus: () => ipcRenderer.invoke('claude-subscription:status'), claudeSubscriptionLogin: () => ipcRenderer.invoke('claude-subscription:login'), + claudeSubscriptionProbe: (token) => ipcRenderer.invoke('claude-subscription:probe', token), claudeSubscriptionModels: (token) => ipcRenderer.invoke('claude-subscription:models', token), claudeSubscriptionSdkStatus: () => ipcRenderer.invoke('claude-subscription:sdk-status'), claudeSubscriptionSdkInstall: () => ipcRenderer.invoke('claude-subscription:sdk-install'), @@ -83,6 +85,8 @@ const api = { return () => ipcRenderer.removeListener('gemini-subscription:cli-progress', wrapped) }, geminiSubscriptionModels: () => ipcRenderer.invoke('gemini-subscription:models'), + geminiCliSubscriptionStatus: () => ipcRenderer.invoke('gemini-cli-subscription:status'), + geminiCliSubscriptionModels: () => ipcRenderer.invoke('gemini-cli-subscription:models'), cursorSubscriptionDiscover: (apiKey) => ipcRenderer.invoke('cursor-subscription:discover', { apiKey }), setSettings: (partial) => @@ -95,6 +99,8 @@ const api = { ipcRenderer.invoke('runtime:settings-sync-status:get'), uploadRuntimeImageAttachment: (request) => ipcRenderer.invoke('runtime:attachment:upload-image', request), + readLocalOfficeDocument: (options) => + ipcRenderer.invoke('file:read-local-office-document', options), resolveKunApproval: (request) => ipcRenderer.invoke('approval:decide', request), restartRuntime: () => ipcRenderer.invoke('runtime:restart'), fetchUpstreamModels: () => ipcRenderer.invoke('upstream:models'), @@ -245,6 +251,8 @@ const api = { ipcRenderer.invoke('file:list-workspace-directory', options), resolveWorkspaceFile: (options) => ipcRenderer.invoke('file:resolve-workspace', options), + openWorkspaceFileInSystem: (options) => + ipcRenderer.invoke('file:open-workspace-system', options), readWorkspaceFile: (options) => ipcRenderer.invoke('file:read-workspace', options), lintProjectDesignMd: (content) => @@ -253,6 +261,10 @@ const api = { ipcRenderer.invoke('file:read-workspace-image', options), readWorkspacePdf: (options) => ipcRenderer.invoke('file:read-workspace-pdf', options), + openWorkspacePreviewResource: (options) => + ipcRenderer.invoke('file:open-workspace-preview', options), + releaseWorkspacePreviewResource: (payload) => + ipcRenderer.invoke('file:release-workspace-preview', payload), readLocalPdfText: (options) => ipcRenderer.invoke('file:read-local-pdf-text', options), saveWorkspaceFileAs: (payload) => diff --git a/src/renderer/src/agent/agent-perspective-events.test.ts b/src/renderer/src/agent/agent-perspective-events.test.ts index 4605c4966..3877eae6d 100644 --- a/src/renderer/src/agent/agent-perspective-events.test.ts +++ b/src/renderer/src/agent/agent-perspective-events.test.ts @@ -178,6 +178,119 @@ describe('Agent Perspective semantic projection', () => { }) }) + it('extracts the nested Gemini CLI Code Assist request without exposing binary or signature data', () => { + const parsed = parseSemanticRequest(trace('1', { + model: 'gemini-3-flash-preview', + project: 'managed-project', + user_prompt_id: 'prompt-1', + request: { + systemInstruction: { + role: 'user', + parts: [{ text: 'Kun Gemini system prompt.' }] + }, + contents: [{ + role: 'user', + parts: [ + { text: 'Inspect the Gemini request' }, + { inlineData: { mimeType: 'image/png', data: 'secret-base64-image' } } + ] + }, { + role: 'model', + parts: [{ + functionCall: { + id: 'gemini-call-1', + name: 'read_file', + args: { path: 'package.json' } + }, + thoughtSignature: 'secret-thought-signature' + }] + }, { + role: 'user', + parts: [{ + functionResponse: { + id: 'gemini-call-1', + name: 'read_file', + response: { output: '{"name":"kun"}' } + } + }] + }], + tools: [{ + functionDeclarations: [{ + name: 'read_file', + description: 'Read a file', + parametersJsonSchema: { + type: 'object', + properties: { path: { type: 'string' } } + } + }] + }], + toolConfig: { functionCallingConfig: { mode: 'AUTO' } }, + generationConfig: { + maxOutputTokens: 256, + thinkingConfig: { thinkingBudget: 8_192, includeThoughts: true } + }, + session_id: 'thread-1' + } + }, { + provider: 'gemini-cli-subscription', + model: 'gemini-3-flash-preview', + endpointFormat: 'gemini-cli-api', + toolCatalog: [{ + name: 'read_file', + providerKind: 'built-in', + providerId: 'builtin' + }] + })) + + expect(parsed).toMatchObject({ + model: 'gemini-3-flash-preview', + prompts: [{ + id: 'systemInstruction', + source: 'system', + text: 'Kun Gemini system prompt.' + }], + tools: [{ + name: 'read_file', + description: 'Read a file', + inputSchema: expect.objectContaining({ type: 'object' }), + provenance: { source: 'kun', inferred: false } + }], + messages: [ + { + role: 'user', + text: 'Inspect the Gemini request\n[inline data: image/png]' + }, + { + role: 'assistant', + callId: 'gemini-call-1', + name: 'read_file', + kind: 'function_call' + }, + { + role: 'tool', + callId: 'gemini-call-1', + name: 'read_file', + kind: 'function_call_output' + } + ] + }) + expect(parsed.parameters.map((parameter) => parameter.name)).toEqual([ + 'project', + 'user_prompt_id', + 'toolConfig', + 'generationConfig', + 'session_id' + ]) + const semanticText = JSON.stringify({ + prompts: parsed.prompts, + tools: parsed.tools, + messages: parsed.messages, + parameters: parsed.parameters + }) + expect(semanticText).not.toContain('secret-base64-image') + expect(semanticText).not.toContain('secret-thought-signature') + }) + it('uses exactly the three supported event classes and matches tool results by call id', () => { const first = trace('1', { model: 'gpt-test', @@ -248,4 +361,216 @@ describe('Agent Perspective semantic projection', () => { expect(parseSemanticRequest(malformed)).toMatchObject({ body: null, prompts: [], tools: [] }) expect(parseSemanticRequest(malformed).parseError).toBeTruthy() }) + + it('preserves delegated SDK continuity metadata on projected requests', () => { + const delegated = trace('1', { + model: 'claude-sonnet-4-5', + system: 'Kun system', + input: 'Continue the task' + }, { + transport: 'sdk', + endpointFormat: 'agent-sdk', + decoded: { + text: 'done', + reasoning: '', + toolCalls: [{ + callId: 'sdk-call-1', + toolName: 'read_file', + arguments: { path: 'README.md' } + }], + toolResults: [{ + callId: 'sdk-call-1', + toolName: 'read_file', + output: 'README content', + isError: false + }] + }, + delegated: { + providerKind: 'agent-sdk', + phase: 'resumed', + contextManagement: 'sdk-managed', + nativeHistory: 'unknown', + capabilities: { + nativeResume: true, + structuredStreaming: true, + kunTools: true, + externalApproval: true, + liveSteering: false, + nativeContextTelemetry: false, + fork: false + } + } + }) + + const events = projectAgentPerspectiveEvents([delegated]) + expect(events[0]).toMatchObject({ + kind: 'llm_request', + record: { + transport: 'sdk', + delegated: { + providerKind: 'agent-sdk', + phase: 'resumed', + nativeHistory: 'unknown' + } + } + }) + expect(events[1]).toMatchObject({ + kind: 'tool_call', + callId: 'sdk-call-1', + result: { + role: 'tool', + text: 'README content', + kind: 'tool_result' + } + }) + }) + + it('projects Claude SDK Kun MCP instructions and original tool provenance', () => { + const claude = trace('claude-kun-tools', { + model: 'claude-sonnet-4-5', + system: 'Kun canonical system prompt.', + instructions: ['Workspace AGENTS.md instruction.'], + input: 'Use the configured Kun tools.', + tools: [{ + name: 'mcp__kun__mcp_docs_lookup', + description: 'Look up MCP docs', + input_schema: { type: 'object' } + }, { + name: 'mcp__kun__extension_render', + description: 'Render through a Kun extension', + input_schema: { type: 'object' } + }] + }, { + transport: 'sdk', + endpointFormat: 'agent-sdk', + toolCatalog: [{ + name: 'mcp__kun__mcp_docs_lookup', + providerKind: 'mcp', + providerId: 'mcp:docs' + }, { + name: 'mcp__kun__extension_render', + providerKind: 'extension', + providerId: 'extension:demo' + }], + decoded: { + text: 'done', + reasoning: '', + toolCalls: [{ + callId: 'sdk-call-extension', + toolName: 'mcp__kun__extension_render', + arguments: {} + }], + toolResults: [] + } + }) + + const semantic = parseSemanticRequest(claude) + expect(semantic.prompts).toEqual(expect.arrayContaining([ + expect.objectContaining({ source: 'system', text: 'Kun canonical system prompt.' }), + expect.objectContaining({ source: 'instructions', text: 'Workspace AGENTS.md instruction.' }) + ])) + expect(semantic.tools).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'mcp__kun__mcp_docs_lookup', + provenance: expect.objectContaining({ + source: 'mcp', + providerId: 'mcp:docs', + inferred: false + }) + }), + expect.objectContaining({ + name: 'mcp__kun__extension_render', + provenance: expect.objectContaining({ + source: 'extension', + providerId: 'extension:demo', + inferred: false + }) + }) + ])) + expect(projectAgentPerspectiveEvents([claude])).toContainEqual(expect.objectContaining({ + kind: 'tool_call', + toolName: 'mcp__kun__extension_render', + provenance: expect.objectContaining({ + source: 'extension', + providerId: 'extension:demo', + inferred: false + }) + })) + }) + + it('projects Cursor SDK Kun instructions and tool provenance', () => { + const cursor = trace('1', { + model: 'cursor-auto', + instructions: [ + 'Kun canonical system prompt.', + 'Workspace AGENTS.md instruction.' + ], + input: 'Use the configured MCP server.', + tools: [{ + name: 'mcp_call_tool', + description: 'Call an MCP tool through Kun', + inputSchema: { type: 'object' } + }, { + name: 'extension_render', + description: 'Render through a Kun extension', + inputSchema: { type: 'object' } + }], + mode: 'agent' + }, { + provider: 'cursor-subscription', + model: 'cursor-auto', + transport: 'sdk', + endpointFormat: 'cursor-sdk', + toolCatalog: [{ + name: 'mcp_call_tool', + providerKind: 'mcp', + providerId: 'mcp:facade' + }, { + name: 'extension_render', + providerKind: 'extension', + providerId: 'extension:demo' + }], + delegated: { + providerKind: 'cursor-sdk', + phase: 'rebased', + contextManagement: 'sdk-managed', + nativeHistory: 'none', + capabilities: { + nativeResume: true, + structuredStreaming: true, + kunTools: true, + externalApproval: true, + liveSteering: false, + nativeContextTelemetry: false, + fork: false + } + } + }) + + expect(parseSemanticRequest(cursor)).toMatchObject({ + prompts: [{ + source: 'instructions', + text: 'Kun canonical system prompt.\nWorkspace AGENTS.md instruction.' + }], + messages: [{ + role: 'user', + text: 'Use the configured MCP server.' + }], + tools: [{ + name: 'mcp_call_tool', + provenance: { + source: 'mcp', + providerName: 'facade', + inferred: false + } + }, { + name: 'extension_render', + provenance: { + source: 'extension', + providerName: 'demo', + inferred: false + } + }] + }) + }) }) diff --git a/src/renderer/src/agent/agent-perspective-events.ts b/src/renderer/src/agent/agent-perspective-events.ts index c4f75376e..c0a087f74 100644 --- a/src/renderer/src/agent/agent-perspective-events.ts +++ b/src/renderer/src/agent/agent-perspective-events.ts @@ -91,13 +91,16 @@ const TITLE_TURN_SUFFIX = '_title' const STRUCTURAL_KEYS = new Set([ 'model', 'messages', 'input', 'instructions', 'system', 'tools' ]) +const GEMINI_STRUCTURAL_KEYS = new Set([ + 'contents', 'systemInstruction', 'thoughtSignature', 'tools' +]) export function projectAgentPerspectiveEvents( records: readonly ModelRequestTraceRecord[] ): AgentPerspectiveEvent[] { const ordered = [...records].sort(oldestRecordFirst) const semanticByRecord = new Map(ordered.map((record) => [record.id, parseSemanticRequest(record)])) - const toolResults = collectToolResults([...semanticByRecord.values()]) + const toolResults = collectToolResults(ordered, [...semanticByRecord.values()]) const events: AgentPerspectiveEvent[] = [] for (const record of ordered) { @@ -157,18 +160,23 @@ export function parseSemanticRequest(record: ModelRequestTraceRecord): SemanticR } } - const prompts = parsePrompts(body) - const messages = parseMessages(body) + const geminiRequest = geminiCodeAssistRequest(record, body) + const prompts = geminiRequest ? parseGeminiPrompts(geminiRequest) : parsePrompts(body) + const messages = geminiRequest ? parseGeminiMessages(geminiRequest) : parseMessages(body) return { body, model: stringValue(body.model) || record.model, prompts, skills: parseSkills(prompts), - tools: parseToolDefinitions(body, record.toolCatalog), + tools: geminiRequest + ? parseGeminiToolDefinitions(geminiRequest, record.toolCatalog) + : parseToolDefinitions(body, record.toolCatalog), messages, - parameters: Object.entries(body) - .filter(([key]) => !STRUCTURAL_KEYS.has(key)) - .map(([name, value]) => ({ name, value })) + parameters: geminiRequest + ? parseGeminiParameters(body, geminiRequest) + : Object.entries(body) + .filter(([key]) => !STRUCTURAL_KEYS.has(key)) + .map(([name, value]) => ({ name, value })) } } @@ -205,6 +213,23 @@ function parsePrompts(body: Record): SemanticPrompt[] { return prompts } +function geminiCodeAssistRequest( + record: ModelRequestTraceRecord, + body: Record +): Record | null { + if (record.endpointFormat !== 'gemini-cli-api') return null + return isRecord(body.request) ? body.request : null +} + +function parseGeminiPrompts(request: Record): SemanticPrompt[] { + const instruction = request.systemInstruction + if (!isRecord(instruction)) return [] + const text = geminiPartsText(instruction.parts) + return text + ? [{ id: 'systemInstruction', source: 'system', text }] + : [] +} + function pushPrompt( prompts: SemanticPrompt[], id: string, @@ -256,6 +281,87 @@ function parseMessages(body: Record): SemanticMessage[] { return messages } +function parseGeminiMessages(request: Record): SemanticMessage[] { + if (!Array.isArray(request.contents)) return [] + const messages: SemanticMessage[] = [] + request.contents.forEach((content, contentIndex) => { + if (!isRecord(content) || !Array.isArray(content.parts)) return + const role = stringValue(content.role) === 'model' + ? 'assistant' + : stringValue(content.role) || 'unknown' + let textParts: string[] = [] + let textPartStart = 0 + const flushText = (partIndex: number): void => { + if (!textParts.length) return + messages.push({ + id: `gemini-content-${contentIndex}-part-${textPartStart}`, + role, + text: textParts.join('\n') + }) + textParts = [] + textPartStart = partIndex + 1 + } + + content.parts.forEach((part, partIndex) => { + if (!isRecord(part)) return + const functionCall = isRecord(part.functionCall) ? part.functionCall : null + const functionResponse = isRecord(part.functionResponse) ? part.functionResponse : null + if (functionCall) { + flushText(partIndex) + const callId = stringValue(functionCall.id) + const name = stringValue(functionCall.name) + messages.push({ + id: `gemini-content-${contentIndex}-function-call-${partIndex}`, + role: 'assistant', + text: contentText(functionCall.args), + ...(callId ? { callId } : {}), + ...(name ? { name } : {}), + kind: 'function_call' + }) + return + } + if (functionResponse) { + flushText(partIndex) + const callId = stringValue(functionResponse.id) + const name = stringValue(functionResponse.name) + messages.push({ + id: `gemini-content-${contentIndex}-function-response-${partIndex}`, + role: 'tool', + text: contentText(functionResponse.response), + ...(callId ? { callId } : {}), + ...(name ? { name } : {}), + kind: 'function_call_output' + }) + return + } + const text = geminiPartText(part) + if (text) textParts.push(text) + }) + flushText(content.parts.length) + }) + return messages +} + +function geminiPartsText(value: unknown): string { + if (!Array.isArray(value)) return '' + return value + .map((part) => isRecord(part) ? geminiPartText(part) : '') + .filter(Boolean) + .join('\n') +} + +function geminiPartText(part: Record): string { + const text = stringValue(part.text) + if (text) return text + if (isRecord(part.inlineData)) { + return `[inline data: ${stringValue(part.inlineData.mimeType) || 'unknown MIME type'}]` + } + if (isRecord(part.fileData)) { + return `[file data: ${stringValue(part.fileData.mimeType) || 'unknown MIME type'}]` + } + return '' +} + function parseNestedToolResults(value: unknown, parentIndex: number): SemanticMessage[] { if (!Array.isArray(value)) return [] return value.flatMap((block, index) => { @@ -276,8 +382,23 @@ function roleForItemType(type: string): string { return '' } -function collectToolResults(requests: readonly SemanticRequest[]): Map { +function collectToolResults( + records: readonly ModelRequestTraceRecord[], + requests: readonly SemanticRequest[] +): Map { const results = new Map() + for (const record of records) { + for (const result of record.decoded?.toolResults ?? []) { + results.set(result.callId, { + id: `trace-tool-result-${record.id}-${result.callId}`, + role: 'tool', + text: result.output, + callId: result.callId, + name: result.toolName, + kind: result.isError ? 'tool_result_error' : 'tool_result' + }) + } + } for (const request of requests) { for (const message of request.messages) { if (message.role === 'tool' && message.callId) results.set(message.callId, message) @@ -315,6 +436,44 @@ function parseToolDefinitions( return [...tools.values()] } +function parseGeminiToolDefinitions( + request: Record, + catalog: ModelRequestTraceRecord['toolCatalog'] +): SemanticToolDefinition[] { + if (!Array.isArray(request.tools)) return [] + const tools = new Map() + for (const group of request.tools) { + if (!isRecord(group) || !Array.isArray(group.functionDeclarations)) continue + for (const definition of group.functionDeclarations) { + if (!isRecord(definition)) continue + const name = stringValue(definition.name) + if (!name) continue + const schema = definition.parametersJsonSchema ?? + definition.parameters ?? + definition.inputSchema + tools.set(name, { + name, + description: stringValue(definition.description), + provenance: resolveToolProvenance(name, catalog), + ...(isRecord(schema) ? { inputSchema: schema } : {}) + }) + } + } + return [...tools.values()] +} + +function parseGeminiParameters( + body: Record, + request: Record +): SemanticParameter[] { + return [ + ...Object.entries(body) + .filter(([key]) => key !== 'model' && key !== 'request'), + ...Object.entries(request) + .filter(([key]) => !GEMINI_STRUCTURAL_KEYS.has(key)) + ].map(([name, value]) => ({ name, value })) +} + function parseSkills(prompts: readonly SemanticPrompt[]): SemanticSkill[] { const skills = new Map() for (const prompt of prompts) { diff --git a/src/renderer/src/agent/kun-contract.ts b/src/renderer/src/agent/kun-contract.ts index 077fabf29..156b967fc 100644 --- a/src/renderer/src/agent/kun-contract.ts +++ b/src/renderer/src/agent/kun-contract.ts @@ -63,10 +63,13 @@ export type CoreAttachmentMetadataJson = { width?: number height?: number documentText?: string + documentFormat?: 'pdf' | 'docx' | 'xlsx' | 'pptx' | 'text' | 'csv' | 'json' | 'xml' + sourceSha256?: string pageCount?: number truncated?: boolean localFilePath?: string textFallback?: CoreAttachmentTextFallbackJson + visualPreview?: CoreAttachmentTextFallbackJson threadIds?: string[] workspaces?: string[] createdAt: string @@ -470,9 +473,11 @@ export type CoreTurnItemJson = { inputId?: string prompt?: string questions?: Array<{ - header: string + header?: string id: string - question: string + question?: string + prompt?: string + message?: string options: Array<{ label: string; description: string }> selectionMode?: 'single' | 'multiple' minSelections?: number @@ -660,6 +665,34 @@ export type CoreRuntimeEventJson = { toolCount?: number changeKind?: 'additive' | 'breaking' toolNames?: string[] + model?: string + providerId?: string + stepIndex?: number + contextWindowTokens?: number + softThresholdTokens?: number + hardThresholdTokens?: number + estimatedInputTokens?: number + breakdown?: { + tools?: number + system?: number + skills?: number + messages?: number + other?: number + } + activeSkillIds?: string[] + contextManagement?: 'kun-managed' | 'sdk-managed' + nativeHistory?: 'known' | 'unknown' | 'none' + providerKind?: 'agent-sdk' | 'cursor-sdk' | 'antigravity-cli' + phase?: 'portable' | 'resumed' | 'rebased' + capabilities?: { + nativeResume?: boolean + structuredStreaming?: boolean + kunTools?: boolean + externalApproval?: boolean + liveSteering?: boolean + nativeContextTelemetry?: boolean + fork?: boolean + } status?: string | number /** thread_created / thread_updated: the thread's (possibly upgraded) title. */ title?: string @@ -684,9 +717,11 @@ export type CoreRuntimeEventJson = { prompt?: string inputId?: string questions?: Array<{ - header: string + header?: string id: string - question: string + question?: string + prompt?: string + message?: string options: Array<{ label: string; description: string }> selectionMode?: 'single' | 'multiple' minSelections?: number diff --git a/src/renderer/src/agent/kun-event-normalizer.ts b/src/renderer/src/agent/kun-event-normalizer.ts index a321a8ea5..0878f9416 100644 --- a/src/renderer/src/agent/kun-event-normalizer.ts +++ b/src/renderer/src/agent/kun-event-normalizer.ts @@ -2,7 +2,9 @@ import type { CoreChildRuntimeMetadataJson, CoreRuntimeEventJson, CoreTurnItemJs import type { ApprovalStatusPayload, CompactionEventPayload, + DelegatedRuntimeState, ReviewEventPayload, + RequestContextSnapshot, RuntimeErrorEventPayload, RuntimeStatusEventPayload, ThreadUsageSnapshot, @@ -32,6 +34,8 @@ export type KunEventNormalizerDeps = { ) => RuntimeProjectionAction goalAction: (event: CoreRuntimeEventJson, cleared: boolean) => RuntimeProjectionAction todosAction: (event: CoreRuntimeEventJson, cleared: boolean) => RuntimeProjectionAction + contextSnapshot: (event: CoreRuntimeEventJson) => RequestContextSnapshot | null + delegatedRuntime: (event: CoreRuntimeEventJson) => DelegatedRuntimeState | null usage: (event: CoreRuntimeEventJson) => ThreadUsageSnapshot | null runtimeError: (event: CoreRuntimeEventJson, fallback: string) => RuntimeErrorEventPayload errorFromRuntime: (payload: RuntimeErrorEventPayload) => Error @@ -114,8 +118,12 @@ export function normalizeKunRuntimeEvent( const status = deps.approvalStatus(event) return status ? [{ type: 'approval_status_changed', payload: status }] : [] } - case 'user_input_requested': - return [{ type: 'user_input_requested', payload: deps.userInputRequest(event) }] + case 'user_input_requested': { + const payload = deps.userInputRequest(event) + return payload.questions.length > 0 + ? [{ type: 'user_input_requested', payload }] + : [] + } case 'user_input_resolved': { const answers = deps.userInputAnswers(event.answers) return [{ @@ -139,6 +147,14 @@ export function normalizeKunRuntimeEvent( return [deps.todosAction(event, false)] case 'todos_cleared': return [deps.todosAction(event, true)] + case 'context_snapshot': { + const snapshot = deps.contextSnapshot(event) + return snapshot ? [{ type: 'context_snapshot_received', payload: snapshot }] : [] + } + case 'delegated_runtime': { + const state = deps.delegatedRuntime(event) + return state ? [{ type: 'delegated_runtime_received', payload: state }] : [] + } case 'usage': { const usage = deps.usage(event) return usage ? [{ type: 'usage_received', payload: usage }] : [] @@ -166,10 +182,17 @@ export function normalizeKunRuntimeEvent( return tool ? [{ type: 'tool_updated', payload: tool }] : [] } const payload = deps.runtimeError(event, 'Kun turn failed') - return [ - { type: 'runtime_error_received', payload }, - { type: 'turn_failed', error: deps.errorFromRuntime(payload), options: { terminal: true } } - ] + const terminal: RuntimeProjectionAction = { + type: 'turn_failed', + error: deps.errorFromRuntime(payload), + options: { terminal: true, scope: 'conversation' } + } + // A message-less terminal event normally follows a more useful + // structured `error` event. Settle the turn without adding a generic + // "Kun turn failed" duplicate to the conversation. + return event.message?.trim() + ? [{ type: 'runtime_error_received', payload }, terminal] + : [terminal] } case 'error': if (event.code === 'compaction_summary_fallback') { diff --git a/src/renderer/src/agent/kun-mapper.test.ts b/src/renderer/src/agent/kun-mapper.test.ts index 35a36359a..104b73001 100644 --- a/src/renderer/src/agent/kun-mapper.test.ts +++ b/src/renderer/src/agent/kun-mapper.test.ts @@ -273,7 +273,32 @@ describe('create_plan tool mapping', () => { message: 'model stream exploded', severity: 'error' }) - expect(capturedErrorOptions).toEqual({ terminal: true }) + expect(capturedErrorOptions).toEqual({ terminal: true, scope: 'conversation' }) + }) + + it('settles message-less turn failures without adding a generic duplicate error', async () => { + let capturedErrorOptions: ThreadErrorOptions | null = null + let runtimeErrorCount = 0 + const sink: ThreadEventSink = { + ...makeSink(), + onRuntimeError: () => { + runtimeErrorCount += 1 + }, + onError: (_error, options) => { + capturedErrorOptions = options ?? null + } + } + + await dispatchKunRuntimeEvent({ + kind: 'turn_failed', + seq: 8, + timestamp: '2024-01-01T00:00:00.000Z', + threadId: 'thr_1', + turnId: 'turn_1' + }, sink, async () => undefined) + + expect(runtimeErrorCount).toBe(0) + expect(capturedErrorOptions).toEqual({ terminal: true, scope: 'conversation' }) }) it('does not finish the parent turn for child lifecycle events', async () => { @@ -1043,6 +1068,79 @@ describe('user input mapping', () => { }) }) + it('maps prompt/message aliases on user-input questions', async () => { + let request: unknown = null + const sink: ThreadEventSink = { + ...makeSink(), + onUserInput: (payload) => { + request = payload + } + } + await dispatchKunRuntimeEvent( + { + kind: 'user_input_requested', + seq: 8, + itemId: 'item_input_alias', + inputId: 'input_alias', + questions: [ + { + id: 'next_action', + prompt: 'Release review finished. What should I do next?', + options: [{ label: 'Fix blockers', description: '' }] + } + ] + }, + sink, + async () => undefined + ) + expect(request).toMatchObject({ + itemId: 'item_input_alias', + requestId: 'input_alias', + questions: [ + { + id: 'next_action', + question: 'Release review finished. What should I do next?', + options: [{ label: 'Fix blockers', description: '' }] + } + ] + }) + }) + + it('drops empty user-input requests instead of inventing placeholder text', async () => { + let request: unknown = null + const sink: ThreadEventSink = { + ...makeSink(), + onUserInput: (payload) => { + request = payload + } + } + await dispatchKunRuntimeEvent( + { + kind: 'user_input_requested', + seq: 9, + itemId: 'item_input_empty', + inputId: 'input_empty', + questions: [{ id: 'blank', options: [{ label: 'Continue', description: '' }] }] + }, + sink, + async () => undefined + ) + expect(request).toBeNull() + expect( + chatBlockFromItem({ + id: 'item_input_empty', + turnId: 'turn_1', + threadId: 'thr_1', + role: 'tool', + status: 'pending', + createdAt: '2024-01-01T00:00:00.000Z', + kind: 'user_input', + inputId: 'input_empty', + questions: [{ id: 'blank', options: [{ label: 'Continue', description: '' }] }] + }) + ).toBeNull() + }) + it('surfaces submitted user-input answers from runtime events', async () => { let status: unknown = null const sink: ThreadEventSink = { @@ -1606,6 +1704,152 @@ describe('usage event mapping', () => { }) }) +describe('context snapshot event mapping', () => { + it('preserves request-local categories and runtime thresholds', () => { + const actions = runtimeProjectionActionsFromEvent({ + kind: 'context_snapshot', + seq: 14, + timestamp: '2026-07-24T00:00:00.000Z', + threadId: 'thr_1', + turnId: 'turn_1', + model: 'deepseek-v4-pro', + providerId: 'deepseek', + stepIndex: 1, + contextWindowTokens: 256_000, + softThresholdTokens: 192_000, + hardThresholdTokens: 217_600, + estimatedInputTokens: 12_000, + breakdown: { + tools: 3_000, + system: 2_000, + skills: 1_000, + messages: 5_000, + other: 1_000 + }, + toolCount: 21, + activeSkillIds: [' skill-a ', '', 'skill-b'] + }) + + expect(actions).toEqual([{ + type: 'context_snapshot_received', + payload: { + threadId: 'thr_1', + turnId: 'turn_1', + model: 'deepseek-v4-pro', + providerId: 'deepseek', + stepIndex: 1, + contextWindowTokens: 256_000, + softThresholdTokens: 192_000, + hardThresholdTokens: 217_600, + estimatedInputTokens: 12_000, + breakdown: { + tools: 3_000, + system: 2_000, + skills: 1_000, + messages: 5_000, + other: 1_000 + }, + toolCount: 21, + activeSkillIds: ['skill-a', 'skill-b'] + } + }]) + }) + + it('drops incomplete snapshot events instead of showing mixed accounting', () => { + expect(runtimeProjectionActionsFromEvent({ + kind: 'context_snapshot', + threadId: 'thr_1', + model: 'deepseek-v4-pro' + })).toEqual([]) + }) + + it('drops snapshots whose declared total does not equal their categories', () => { + expect(runtimeProjectionActionsFromEvent({ + kind: 'context_snapshot', + threadId: 'thr_1', + model: 'deepseek-v4-pro', + stepIndex: 0, + contextWindowTokens: 256_000, + softThresholdTokens: 192_000, + hardThresholdTokens: 217_600, + estimatedInputTokens: 999, + breakdown: { tools: 1, system: 2, skills: 3, messages: 4, other: 5 }, + toolCount: 1, + activeSkillIds: [] + })).toEqual([]) + }) + + it('preserves SDK-managed unknown native history without inventing occupancy', () => { + const actions = runtimeProjectionActionsFromEvent({ + kind: 'context_snapshot', + threadId: 'thr_1', + turnId: 'turn_2', + model: 'claude-sonnet-4-5', + providerId: 'claude-subscription', + stepIndex: 0, + contextWindowTokens: 200_000, + softThresholdTokens: 150_000, + hardThresholdTokens: 170_000, + estimatedInputTokens: 12, + breakdown: { tools: 1, system: 2, skills: 3, messages: 6, other: 0 }, + toolCount: 1, + activeSkillIds: [], + contextManagement: 'sdk-managed', + nativeHistory: 'unknown' + }) + expect(actions).toEqual([{ + type: 'context_snapshot_received', + payload: expect.objectContaining({ + contextManagement: 'sdk-managed', + nativeHistory: 'unknown', + estimatedInputTokens: 12 + }) + }]) + }) +}) + +describe('delegated runtime capability mapping', () => { + it('maps bounded capability and rebase state without a native session id', () => { + expect(runtimeProjectionActionsFromEvent({ + kind: 'delegated_runtime', + threadId: 'thr_1', + turnId: 'turn_1', + providerKind: 'cursor-sdk', + providerId: 'cursor-subscription', + phase: 'rebased', + reason: 'history_changed', + capabilities: { + nativeResume: true, + structuredStreaming: true, + kunTools: false, + externalApproval: false, + liveSteering: false, + nativeContextTelemetry: false, + fork: false + } + })).toEqual([{ + type: 'delegated_runtime_received', + payload: { + threadId: 'thr_1', + turnId: 'turn_1', + providerKind: 'cursor-sdk', + providerId: 'cursor-subscription', + phase: 'rebased', + reason: 'history_changed', + capabilities: { + nativeResume: true, + structuredStreaming: true, + kunTools: false, + externalApproval: false, + liveSteering: false, + nativeContextTelemetry: false, + fork: false + } + } + }]) + }) +}) + describe('tool presentation inference', () => { it('prefers explicit toolKind from Kun over local heuristics', () => { const block = chatBlockFromItem({ diff --git a/src/renderer/src/agent/kun-mapper.ts b/src/renderer/src/agent/kun-mapper.ts index d7aba3567..f07e8f9ec 100644 --- a/src/renderer/src/agent/kun-mapper.ts +++ b/src/renderer/src/agent/kun-mapper.ts @@ -3,12 +3,14 @@ import type { ChatBlock, CompactionEventPayload, ComponentPrototypeMetadata, + DelegatedRuntimeState, GeneratedFileReference, NormalizedThread, ReviewBlock, ReviewEventPayload, ReviewOutput, ReviewTarget, + RequestContextSnapshot, RuntimeErrorEventPayload, RuntimeStatusEventPayload, ThreadGoal, @@ -928,19 +930,32 @@ function questionsFromCore( .map((question) => normalizeUserInputQuestion(question)) .filter((question): question is UserInputQuestion => question !== null) } + const promptText = typeof prompt === 'string' ? prompt.trim() : '' + if (!promptText) return [] return [ { header: 'Input', id: fallbackId, - question: prompt?.trim() || 'Input requested', + question: promptText, options: [] } ] } +function firstNonEmptyUserInputText(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value !== 'string') continue + const normalized = value.trim() + if (normalized) return normalized + } + return undefined +} + function normalizeUserInputQuestion(question: unknown): UserInputQuestion | null { if (!question || typeof question !== 'object') return null const raw = question as Record + const text = firstNonEmptyUserInputText(raw.question, raw.prompt, raw.message) + if (!text) return null const options = Array.isArray(raw.options) ? raw.options .map((option) => normalizeUserInputOption(option)) @@ -949,7 +964,7 @@ function normalizeUserInputQuestion(question: unknown): UserInputQuestion | null return { header: typeof raw.header === 'string' && raw.header.trim() ? raw.header.trim() : 'Input', id: typeof raw.id === 'string' && raw.id.trim() ? raw.id.trim() : 'input', - question: typeof raw.question === 'string' && raw.question.trim() ? raw.question.trim() : 'Input requested', + question: text, options, selectionMode: raw.selectionMode === 'multiple' && options.length > 0 ? 'multiple' : 'single', ...(positiveInteger(raw.minSelections) ? { minSelections: positiveInteger(raw.minSelections) } : {}), @@ -963,6 +978,12 @@ function positiveInteger(value: unknown): number | undefined { return normalized > 0 ? normalized : undefined } +function nonnegativeInteger(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) return undefined + const normalized = Math.floor(value) + return normalized >= 0 ? normalized : undefined +} + function normalizeUserInputOption(option: unknown): UserInputQuestion['options'][number] | null { if (!option || typeof option !== 'object') return null const raw = option as Record @@ -1033,6 +1054,118 @@ function usageFromCore(usage: CoreUsageSnapshotJson): ThreadUsageSnapshot { } } +function contextSnapshotFromCore(event: CoreRuntimeEventJson): RequestContextSnapshot | null { + const threadId = event.threadId?.trim() + const model = event.model?.trim() + const contextWindowTokens = positiveInteger(event.contextWindowTokens) + const softThresholdTokens = positiveInteger(event.softThresholdTokens) + const hardThresholdTokens = positiveInteger(event.hardThresholdTokens) + const estimatedInputTokens = nonnegativeInteger(event.estimatedInputTokens) + const stepIndex = nonnegativeInteger(event.stepIndex) + const toolCount = nonnegativeInteger(event.toolCount) + const rawBreakdown = event.breakdown + const tools = nonnegativeInteger(rawBreakdown?.tools) + const system = nonnegativeInteger(rawBreakdown?.system) + const skills = nonnegativeInteger(rawBreakdown?.skills) + const messages = nonnegativeInteger(rawBreakdown?.messages) + const other = nonnegativeInteger(rawBreakdown?.other) + if ( + !threadId || + !model || + contextWindowTokens === undefined || + softThresholdTokens === undefined || + hardThresholdTokens === undefined || + estimatedInputTokens === undefined || + stepIndex === undefined || + toolCount === undefined || + tools === undefined || + system === undefined || + skills === undefined || + messages === undefined || + other === undefined + ) { + return null + } + if (tools + system + skills + messages + other !== estimatedInputTokens) return null + return { + threadId, + ...(event.turnId?.trim() ? { turnId: event.turnId.trim() } : {}), + model, + ...(event.providerId?.trim() ? { providerId: event.providerId.trim() } : {}), + stepIndex, + contextWindowTokens, + softThresholdTokens, + hardThresholdTokens, + estimatedInputTokens, + breakdown: { tools, system, skills, messages, other }, + toolCount, + activeSkillIds: Array.isArray(event.activeSkillIds) + ? event.activeSkillIds.filter((id): id is string => typeof id === 'string' && id.trim().length > 0) + .map((id) => id.trim()) + : [], + ...(event.contextManagement === 'kun-managed' || event.contextManagement === 'sdk-managed' + ? { contextManagement: event.contextManagement } + : {}), + ...(event.nativeHistory === 'known' || + event.nativeHistory === 'unknown' || + event.nativeHistory === 'none' + ? { nativeHistory: event.nativeHistory } + : {}) + } +} + +function delegatedRuntimeFromCore(event: CoreRuntimeEventJson): DelegatedRuntimeState | null { + const threadId = event.threadId?.trim() + const providerId = event.providerId?.trim() + const providerKind = event.providerKind + const phase = event.phase + const capabilities = event.capabilities + if ( + !threadId || + !providerId || + ( + providerKind !== 'agent-sdk' && + providerKind !== 'cursor-sdk' && + providerKind !== 'antigravity-cli' + ) || + (phase !== 'portable' && phase !== 'resumed' && phase !== 'rebased') || + !capabilities || + ![ + capabilities.nativeResume, + capabilities.structuredStreaming, + capabilities.kunTools, + capabilities.externalApproval, + capabilities.liveSteering, + capabilities.nativeContextTelemetry, + capabilities.fork + ].every((value) => typeof value === 'boolean') + ) return null + const reason = event.reason + return { + threadId, + ...(event.turnId?.trim() ? { turnId: event.turnId.trim() } : {}), + providerKind, + providerId, + phase, + ...(reason === 'new' || + reason === 'route_changed' || + reason === 'capabilities_changed' || + reason === 'history_changed' || + reason === 'native_state_unavailable' + ? { reason } + : {}), + capabilities: { + nativeResume: capabilities.nativeResume!, + structuredStreaming: capabilities.structuredStreaming!, + kunTools: capabilities.kunTools!, + externalApproval: capabilities.externalApproval!, + liveSteering: capabilities.liveSteering!, + nativeContextTelemetry: capabilities.nativeContextTelemetry!, + fork: capabilities.fork! + } + } +} + function userMessageBlockFromItem(item: CoreTurnItemJson): ChatBlock | null { const meta: Record = {} applyRuntimeDisclosureMeta(meta, item) @@ -1103,7 +1236,9 @@ function approvalStatusFromEvent(event: CoreRuntimeEventJson): ApprovalStatusPay } } -function userInputBlockFromItem(item: CoreTurnItemJson): ChatBlock { +function userInputBlockFromItem( + item: CoreTurnItemJson +): Extract { const answers = userInputAnswersFromCore(item.answers) return { kind: 'user_input', @@ -1252,6 +1387,7 @@ function systemErrorBlockFromItem(item: CoreTurnItemJson): ChatBlock { return { kind: 'system', id: item.id, + turnId: item.turnId, createdAt: itemCreatedAt(item), text: redactSecretText(message), ...(item.code ? { code: item.code } : {}), @@ -1265,6 +1401,7 @@ function runtimeErrorFromItem(item: CoreTurnItemJson): RuntimeErrorEventPayload const message = item.message ?? 'Runtime error' return { itemId: item.id, + turnId: item.turnId, createdAt: itemCreatedAt(item), message: redactSecretText(message), ...(item.code ? { code: item.code } : {}), @@ -1281,6 +1418,7 @@ function runtimeErrorFromEvent( const itemId = event.itemId ?? `runtime_error_${event.turnId ?? event.threadId ?? event.seq ?? Date.now()}` return { itemId, + ...(event.turnId ? { turnId: event.turnId } : {}), createdAt: event.timestamp, message: redactSecretText(message), ...(event.code ? { code: event.code } : {}), @@ -1316,8 +1454,10 @@ export function chatBlockFromItem(item: CoreTurnItemJson, child?: CoreChildRunti return toolBlockFromItem(item, child) case 'approval': return approvalBlockFromItem(item, child) - case 'user_input': - return userInputBlockFromItem(item) + case 'user_input': { + const block = userInputBlockFromItem(item) + return block.questions.length > 0 ? block : null + } case 'compaction': return compactionBlockFromItem(item) case 'review': @@ -1540,6 +1680,8 @@ const kunEventNormalizerDeps: KunEventNormalizerDeps = { createdAt: event.timestamp } }), + contextSnapshot: contextSnapshotFromCore, + delegatedRuntime: delegatedRuntimeFromCore, usage: (event) => event.usage ? usageFromCore(event.usage) : null, runtimeError: runtimeErrorFromEvent, errorFromRuntime: errorForRuntimeEvent @@ -1573,6 +1715,8 @@ async function applyRuntimeProjectionAction( case 'goal_changed': sink.onGoal(action.payload); return case 'todos_changed': sink.onTodos?.(action.payload); return case 'thread_metadata_changed': sink.onThreadUpdated?.(action.payload); return + case 'context_snapshot_received': sink.onContextSnapshot?.(action.payload); return + case 'delegated_runtime_received': sink.onDelegatedRuntimeState?.(action.payload); return case 'usage_received': sink.onUsage?.(action.payload); return case 'turn_completed': sink.onTurnComplete(); return case 'turn_failed': sink.onError(action.error, action.options); return diff --git a/src/renderer/src/agent/kun-runtime.test.ts b/src/renderer/src/agent/kun-runtime.test.ts index 049a35336..72c7fef23 100644 --- a/src/renderer/src/agent/kun-runtime.test.ts +++ b/src/renderer/src/agent/kun-runtime.test.ts @@ -30,7 +30,7 @@ function settings(): AppSettingsV1 { workspaceRoot: '/tmp/workspace', conversationWorkspaceRoot: '~/Documents/Kun', log: { enabled: false, retentionDays: 7 }, - checkpointCleanup: { enabled: false, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, notifications: { turnComplete: true }, appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, keyboardShortcuts: defaultKeyboardShortcuts(), @@ -196,6 +196,61 @@ describe('KunRuntimeProvider', () => { expect(detail.latestUserMessageId).toBe('item_user') }) + it('rehydrates persisted partial assistant output for a running turn', async () => { + installDsGui({ + runtimeRequest: vi.fn(async () => ({ + ok: true, + status: 200, + body: JSON.stringify({ + id: 'thr_cursor', + title: 'Cursor turn', + workspace: '/tmp', + model: 'grok-4.5', + mode: 'agent', + status: 'running', + createdAt: 't0', + updatedAt: 't1', + latestSeq: 42, + turns: [{ + id: 'turn_cursor', + threadId: 'thr_cursor', + status: 'running', + prompt: 'review', + createdAt: 't0', + items: [{ + id: 'item_user', + turnId: 'turn_cursor', + threadId: 'thr_cursor', + role: 'user', + status: 'completed', + createdAt: 't0', + kind: 'user_message', + text: 'review' + }, { + id: 'item_cursor_text', + turnId: 'turn_cursor', + threadId: 'thr_cursor', + role: 'assistant', + status: 'running', + createdAt: 't1', + kind: 'assistant_text', + text: 'partial Cursor response' + }] + }] + }) + })) + }) + + const detail = await new KunRuntimeProvider().getThreadDetail('thr_cursor') + + expect(detail.threadStatus).toBe('running') + expect(detail.blocks).toContainEqual(expect.objectContaining({ + kind: 'assistant', + id: 'item_cursor_text', + text: 'partial Cursor response' + })) + }) + it('flags user_input blocks live only when the runtime gate still awaits them (#606)', async () => { const threadBody = (pendingUserInputIds: string[]): string => JSON.stringify({ diff --git a/src/renderer/src/agent/kun-runtime.ts b/src/renderer/src/agent/kun-runtime.ts index a5269028c..8189cf95c 100644 --- a/src/renderer/src/agent/kun-runtime.ts +++ b/src/renderer/src/agent/kun-runtime.ts @@ -766,9 +766,12 @@ export class KunRuntimeProvider implements AgentProvider { mimeType?: string dataBase64: string documentText?: string + documentFormat?: 'pdf' | 'docx' | 'xlsx' | 'pptx' | 'text' | 'csv' | 'json' | 'xml' + sourceSha256?: string pageCount?: number localFilePath?: string textFallback?: CoreAttachmentTextFallbackJson + visualPreview?: CoreAttachmentTextFallbackJson threadId?: string workspace?: string }): Promise { diff --git a/src/renderer/src/agent/model-request-traces.test.ts b/src/renderer/src/agent/model-request-traces.test.ts index 3941cab10..4c5a995b8 100644 --- a/src/renderer/src/agent/model-request-traces.test.ts +++ b/src/renderer/src/agent/model-request-traces.test.ts @@ -50,6 +50,12 @@ function record(id = 'trace-1') { text: 'hello', reasoning: '', toolCalls: [], + toolResults: [{ + callId: 'call-1', + toolName: 'read_file', + output: 'done', + isError: false + }], usage: { inputTokens: 12 } } } @@ -83,7 +89,10 @@ describe('model request trace renderer contract', () => { } }, response: { status: 200 }, - decoded: { text: 'hello' } + decoded: { + text: 'hello', + toolResults: [{ callId: 'call-1', output: 'done' }] + } }) expect(parsed.records[0]?.response?.body?.text).toContain('data:') }) @@ -135,7 +144,23 @@ describe('model request trace renderer contract', () => { url: 'cursor-sdk://local/agent', headers: { values: {}, redactedNames: [] } }, - response: undefined + response: undefined, + delegated: { + providerKind: 'agent-sdk', + phase: 'rebased', + reason: 'native_state_unavailable', + contextManagement: 'sdk-managed', + nativeHistory: 'none', + capabilities: { + nativeResume: true, + structuredStreaming: true, + kunTools: true, + externalApproval: true, + liveSteering: false, + nativeContextTelemetry: false, + fork: false + } + } }])) expect(parsed.records[0]).toMatchObject({ @@ -144,11 +169,30 @@ describe('model request trace renderer contract', () => { request: { method: 'SDK', url: 'cursor-sdk://local/agent' + }, + delegated: { + providerKind: 'agent-sdk', + phase: 'rebased', + reason: 'native_state_unavailable', + nativeHistory: 'none' } }) expect(parsed.records[0].response).toBeUndefined() }) + it('rejects malformed delegated capabilities instead of guessing support', () => { + expect(() => parseModelRequestTracePage(page([{ + ...record(), + delegated: { + providerKind: 'cursor-sdk', + phase: 'resumed', + contextManagement: 'sdk-managed', + nativeHistory: 'unknown', + capabilities: { nativeResume: true } + } + }]))).toThrow('capabilities.structuredStreaming') + }) + it('rejects malformed JSON and unbounded header values', () => { expect(() => parseModelRequestTracePageJson('{')).toThrow('invalid model request trace JSON') expect(() => parseModelRequestTracePage(page([{ diff --git a/src/renderer/src/agent/model-request-traces.ts b/src/renderer/src/agent/model-request-traces.ts index ed3893240..79de62894 100644 --- a/src/renderer/src/agent/model-request-traces.ts +++ b/src/renderer/src/agent/model-request-traces.ts @@ -31,6 +31,28 @@ export type ModelRequestTraceToolCatalogEntry = { providerId?: string } +export type ModelRequestTraceDelegated = { + providerKind: 'agent-sdk' | 'cursor-sdk' | 'antigravity-cli' + phase: 'portable' | 'resumed' | 'rebased' + reason?: + | 'new' + | 'route_changed' + | 'capabilities_changed' + | 'history_changed' + | 'native_state_unavailable' + contextManagement: 'sdk-managed' + nativeHistory: 'known' | 'unknown' | 'none' + capabilities: { + nativeResume: boolean + structuredStreaming: boolean + kunTools: boolean + externalApproval: boolean + liveSteering: boolean + nativeContextTelemetry: boolean + fork: boolean + } +} + export type ModelRequestTraceRecord = { schemaVersion: 1 id: string @@ -56,6 +78,7 @@ export type ModelRequestTraceRecord = { headers: ModelRequestTraceHeaders body: ModelRequestTraceBody } + delegated?: ModelRequestTraceDelegated toolCatalog?: ModelRequestTraceToolCatalogEntry[] response?: { status: number @@ -68,6 +91,7 @@ export type ModelRequestTraceRecord = { text: string reasoning: string toolCalls: Array<{ callId: string; toolName: string; arguments: Record }> + toolResults?: Array<{ callId: string; toolName: string; output: string; isError: boolean }> usage?: Record stopReason?: string error?: string @@ -180,11 +204,67 @@ function parseRecord(value: unknown, label: string): ModelRequestTraceRecord { if (input.toolCatalog !== undefined) { parsed.toolCatalog = parseToolCatalog(input.toolCatalog) } + if (input.delegated !== undefined) { + parsed.delegated = parseDelegated(input.delegated, `${label}.delegated`) + } if (input.response !== undefined) parsed.response = parseResponse(input.response, `${label}.response`) if (input.decoded !== undefined) parsed.decoded = parseDecoded(input.decoded, `${label}.decoded`) return parsed } +function parseDelegated(value: unknown, label: string): ModelRequestTraceDelegated { + const input = object(value, label) + const capabilities = object(input.capabilities, `${label}.capabilities`) + const reason = input.reason === undefined + ? undefined + : oneOf(input.reason, `${label}.reason`, [ + 'new', + 'route_changed', + 'capabilities_changed', + 'history_changed', + 'native_state_unavailable' + ] as const) + return { + providerKind: oneOf(input.providerKind, `${label}.providerKind`, [ + 'agent-sdk', + 'cursor-sdk', + 'antigravity-cli' + ] as const), + phase: oneOf(input.phase, `${label}.phase`, [ + 'portable', + 'resumed', + 'rebased' + ] as const), + ...(reason ? { reason } : {}), + contextManagement: oneOf(input.contextManagement, `${label}.contextManagement`, [ + 'sdk-managed' + ] as const), + nativeHistory: oneOf(input.nativeHistory, `${label}.nativeHistory`, [ + 'known', + 'unknown', + 'none' + ] as const), + capabilities: { + nativeResume: bool(capabilities.nativeResume, `${label}.capabilities.nativeResume`), + structuredStreaming: bool( + capabilities.structuredStreaming, + `${label}.capabilities.structuredStreaming` + ), + kunTools: bool(capabilities.kunTools, `${label}.capabilities.kunTools`), + externalApproval: bool( + capabilities.externalApproval, + `${label}.capabilities.externalApproval` + ), + liveSteering: bool(capabilities.liveSteering, `${label}.capabilities.liveSteering`), + nativeContextTelemetry: bool( + capabilities.nativeContextTelemetry, + `${label}.capabilities.nativeContextTelemetry` + ), + fork: bool(capabilities.fork, `${label}.capabilities.fork`) + } + } +} + function parseToolCatalog(value: unknown): ModelRequestTraceToolCatalogEntry[] { if (!Array.isArray(value)) return [] const tools: ModelRequestTraceToolCatalogEntry[] = [] @@ -235,6 +315,19 @@ function parseDecoded(value: unknown, label: string): NonNullable { + const result = object(value, `${label}.toolResults[${index}]`) + return { + callId: text(result.callId, `${label}.toolResults[${index}].callId`, 512), + toolName: text(result.toolName, `${label}.toolResults[${index}].toolName`, 512), + output: text(result.output, `${label}.toolResults[${index}].output`, MAX_TRACE_TEXT_CHARS), + isError: bool(result.isError, `${label}.toolResults[${index}].isError`) + } + } + ) + } if (input.usage !== undefined) decoded.usage = object(input.usage, `${label}.usage`) for (const key of ['stopReason', 'error'] as const) { if (input[key] !== undefined) decoded[key] = text(input[key], `${label}.${key}`, 4_096) diff --git a/src/renderer/src/agent/runtime-client.test.ts b/src/renderer/src/agent/runtime-client.test.ts index 81ee98c18..6661e65f1 100644 --- a/src/renderer/src/agent/runtime-client.test.ts +++ b/src/renderer/src/agent/runtime-client.test.ts @@ -30,7 +30,7 @@ function settings(apiKey: string): AppSettingsV1 { workspaceRoot: '/tmp/workspace', conversationWorkspaceRoot: '~/Documents/Kun', log: { enabled: false, retentionDays: 7 }, - checkpointCleanup: { enabled: false, intervalDays: 3 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, notifications: { turnComplete: true }, appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, keyboardShortcuts: defaultKeyboardShortcuts(), @@ -103,6 +103,38 @@ describe('rendererRuntimeClient', () => { expect(setSettings).toHaveBeenCalledTimes(1) }) + it('invalidates cached settings after encrypted credentials are reset', async () => { + const getSettings = vi.fn() + .mockResolvedValueOnce(settings('')) + .mockResolvedValueOnce(settings('sk-after-reset')) + const resetUnreadableCredentials = vi.fn(async () => ({ + reset: true as const, + backupPath: '/tmp/credential-recovery', + movedItems: ['secret.key'] + })) + vi.stubGlobal('window', { + kunGui: { + getSettings, + setSettings: vi.fn(), + resetUnreadableCredentials, + runtimeRequest: vi.fn(), + restartRuntime: vi.fn(), + startSse: vi.fn(), + stopSse: vi.fn(), + onSseEvent: vi.fn(), + onSseEnd: vi.fn(), + onSseError: vi.fn() + } + }) + + await rendererRuntimeClient.getSettings() + await expect(rendererRuntimeClient.resetUnreadableCredentials()).resolves.toMatchObject({ reset: true }) + const refreshed = await rendererRuntimeClient.getSettings() + + expect(refreshed.agents.kun.apiKey).toBe('sk-after-reset') + expect(getSettings).toHaveBeenCalledTimes(2) + }) + it('forwards explicit runtime restarts through the preload bridge', async () => { const restartRuntime = vi.fn(async () => undefined) vi.stubGlobal('window', { diff --git a/src/renderer/src/agent/runtime-client.ts b/src/renderer/src/agent/runtime-client.ts index 613008640..5c5a2c800 100644 --- a/src/renderer/src/agent/runtime-client.ts +++ b/src/renderer/src/agent/runtime-client.ts @@ -1,5 +1,6 @@ import type { AppSettingsPatch, AppSettingsV1 } from '@shared/app-settings' import type { + CredentialRecoveryResetResult, RuntimeRequestResult, SseEndPayload, SseErrorPayload, @@ -33,6 +34,12 @@ class RendererRuntimeClient { return settings } + async resetUnreadableCredentials(): Promise { + const result = await window.kunGui.resetUnreadableCredentials() + if (result.reset) this.invalidateSettings() + return result + } + invalidateSettings(): void { this.cachedSettings = null this.settingsPromise = null diff --git a/src/renderer/src/agent/runtime-projection-actions.ts b/src/renderer/src/agent/runtime-projection-actions.ts index 1c23091f2..768a4b022 100644 --- a/src/renderer/src/agent/runtime-projection-actions.ts +++ b/src/renderer/src/agent/runtime-projection-actions.ts @@ -5,6 +5,7 @@ import type { ReviewEventPayload, RuntimeErrorEventPayload, RuntimeStatusEventPayload, + RequestContextSnapshot, ChatBlock, ThreadGoal, ThreadTodoList, @@ -12,6 +13,7 @@ import type { ThreadErrorOptions, ThreadEventSink, ThreadUsageSnapshot, + DelegatedRuntimeState, ToolEventPayload, UserInputRequestPayload, UserInputStatusPayload, @@ -44,6 +46,8 @@ export type RuntimeProjectionAction = | { type: 'goal_changed'; payload: GoalProjection } | { type: 'todos_changed'; payload: TodoProjection } | { type: 'thread_metadata_changed'; payload: ThreadMetadataProjection } + | { type: 'context_snapshot_received'; payload: RequestContextSnapshot } + | { type: 'delegated_runtime_received'; payload: DelegatedRuntimeState } | { type: 'usage_received'; payload: ThreadUsageSnapshot } | { type: 'thread_snapshot_reconciled' diff --git a/src/renderer/src/agent/types.ts b/src/renderer/src/agent/types.ts index 005464fe0..f61438113 100644 --- a/src/renderer/src/agent/types.ts +++ b/src/renderer/src/agent/types.ts @@ -27,6 +27,9 @@ export type AttachmentReference = { truncated?: boolean textPreview?: string documentText?: string + documentFormat?: 'pdf' | 'docx' | 'xlsx' | 'pptx' | 'text' | 'csv' | 'json' | 'xml' + sourceSha256?: string + previewUnavailableReason?: string previewUrl?: string } @@ -336,6 +339,7 @@ export type ChatBlock = | { kind: 'system' id: string + turnId?: string createdAt?: string text: string code?: string @@ -421,6 +425,7 @@ export type RuntimeStatusEventPayload = { export type RuntimeErrorEventPayload = { itemId: string + turnId?: string createdAt?: string message: string code?: string @@ -481,6 +486,11 @@ export type ThreadDeltaEvent = { export type ThreadErrorOptions = { terminal?: boolean + /** + * Conversation-scoped failures already have a durable runtime-error card in + * the owning thread. Runtime-scoped failures use the global recovery banner. + */ + scope?: 'conversation' | 'runtime' } /** Cumulative usage/cost for a Kun thread. */ @@ -498,6 +508,52 @@ export type ThreadUsageSnapshot = { turns: number } +export type RequestContextSnapshot = { + threadId: string + turnId?: string + model: string + providerId?: string + stepIndex: number + contextWindowTokens: number + softThresholdTokens: number + hardThresholdTokens: number + estimatedInputTokens: number + breakdown: { + tools: number + system: number + skills: number + messages: number + other: number + } + toolCount: number + activeSkillIds: string[] + contextManagement?: 'kun-managed' | 'sdk-managed' + nativeHistory?: 'known' | 'unknown' | 'none' +} + +export type DelegatedRuntimeState = { + threadId: string + turnId?: string + providerKind: 'agent-sdk' | 'cursor-sdk' | 'antigravity-cli' + providerId: string + phase: 'portable' | 'resumed' | 'rebased' + reason?: + | 'new' + | 'route_changed' + | 'capabilities_changed' + | 'history_changed' + | 'native_state_unavailable' + capabilities: { + nativeResume: boolean + structuredStreaming: boolean + kunTools: boolean + externalApproval: boolean + liveSteering: boolean + nativeContextTelemetry: boolean + fork: boolean + } +} + export type ThreadEventSink = { onSeq(seq: number): void onDeltas(deltas: ThreadDeltaEvent[]): void @@ -519,6 +575,9 @@ export type ThreadEventSink = { onError(err: Error, options?: ThreadErrorOptions): void /** Optional: cumulative usage update for the thread. */ onUsage?(usage: ThreadUsageSnapshot): void + /** Optional: request-local context accounting for the main agent. */ + onContextSnapshot?(snapshot: RequestContextSnapshot): void + onDelegatedRuntimeState?(state: DelegatedRuntimeState): void } export interface AgentProvider { @@ -597,9 +656,12 @@ export interface AgentProvider { mimeType?: string dataBase64: string documentText?: string + documentFormat?: 'pdf' | 'docx' | 'xlsx' | 'pptx' | 'text' | 'csv' | 'json' | 'xml' + sourceSha256?: string pageCount?: number localFilePath?: string textFallback?: CoreAttachmentTextFallbackJson + visualPreview?: CoreAttachmentTextFallbackJson threadId?: string workspace?: string }): Promise diff --git a/src/renderer/src/components/InitialSetupDialog.test.ts b/src/renderer/src/components/InitialSetupDialog.test.ts index a9c451cc1..3ec2eb855 100644 --- a/src/renderer/src/components/InitialSetupDialog.test.ts +++ b/src/renderer/src/components/InitialSetupDialog.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { canCloseInitialSetup, - completeInitialSetupAfterSave + completeInitialSetupAfterSave, + dismissInitialSetup, + isUnreadableCredentialKeyError } from './InitialSetupDialog' describe('InitialSetupDialog completion flow', () => { @@ -75,8 +77,53 @@ describe('InitialSetupDialog completion flow', () => { expect(closeInitialSetup).toHaveBeenCalledTimes(1) }) - it('only allows manual close in preview mode', () => { - expect(canCloseInitialSetup('required')).toBe(false) + it('allows users to dismiss both required and preview setup flows', () => { + expect(canCloseInitialSetup('required')).toBe(true) expect(canCloseInitialSetup('preview')).toBe(true) }) + + it('persists a required dismissal and starts probing Kun after closing', async () => { + const persistCompletion = vi.fn(async () => undefined) + const reloadUiSettings = vi.fn(async () => undefined) + const probeRuntime = vi.fn(async () => undefined) + const closeInitialSetup = vi.fn() + + await dismissInitialSetup({ + mode: 'required', + persistCompletion, + reloadUiSettings, + probeRuntime, + closeInitialSetup + }) + + expect(persistCompletion).toHaveBeenCalledTimes(1) + expect(reloadUiSettings).toHaveBeenCalledTimes(1) + expect(closeInitialSetup).toHaveBeenCalledTimes(1) + expect(probeRuntime).toHaveBeenCalledWith('user') + }) + + it('does not persist or start Kun when closing the settings preview', async () => { + const persistCompletion = vi.fn(async () => undefined) + const probeRuntime = vi.fn(async () => undefined) + const closeInitialSetup = vi.fn() + + await dismissInitialSetup({ + mode: 'preview', + persistCompletion, + reloadUiSettings: vi.fn(async () => undefined), + probeRuntime, + closeInitialSetup + }) + + expect(persistCompletion).not.toHaveBeenCalled() + expect(probeRuntime).not.toHaveBeenCalled() + expect(closeInitialSetup).toHaveBeenCalledTimes(1) + }) + + it('recognizes unreadable protected credential errors across the Electron IPC wrapper', () => { + expect(isUnreadableCredentialKeyError(new Error( + "Error invoking remote method 'settings:set': credential_key_unreadable: existing key is unavailable" + ))).toBe(true) + expect(isUnreadableCredentialKeyError(new Error('Kun runtime is offline'))).toBe(false) + }) }) diff --git a/src/renderer/src/components/InitialSetupDialog.tsx b/src/renderer/src/components/InitialSetupDialog.tsx index d663758d7..68befdf3d 100644 --- a/src/renderer/src/components/InitialSetupDialog.tsx +++ b/src/renderer/src/components/InitialSetupDialog.tsx @@ -11,6 +11,7 @@ import { type KunToolPermissionMode, type ModelProviderPreset } from '@shared/app-settings' +import { UNREADABLE_CREDENTIAL_KEY_ERROR_CODE } from '@shared/kun-gui-api' import { buildInitialSetupSettingsPatch, INITIAL_SETUP_PROVIDER_PRESETS, @@ -43,6 +44,8 @@ import { Sun, Moon, Monitor, + RotateCcw, + ShieldAlert, X } from 'lucide-react' @@ -166,8 +169,13 @@ function keyPlaceholder(card: SetupProviderCard, mode: InitialSetupSelection['mo return card.presetId === 'minimax' ? 'API Key' : 'sk-...' } -export function canCloseInitialSetup(mode: InitialSetupMode): boolean { - return mode === 'preview' +export function canCloseInitialSetup(_mode: InitialSetupMode): boolean { + return true +} + +export function isUnreadableCredentialKeyError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return message.includes(UNREADABLE_CREDENTIAL_KEY_ERROR_CODE) } export async function completeInitialSetupAfterSave(input: { @@ -198,6 +206,23 @@ export async function completeInitialSetupAfterSave(input: { return true } +export async function dismissInitialSetup(input: { + mode: InitialSetupMode + persistCompletion: () => Promise + reloadUiSettings: () => Promise + probeRuntime: (mode?: 'user' | 'background') => Promise + closeInitialSetup: () => void +}): Promise { + if (input.mode === 'required') { + await input.persistCompletion() + } + await input.reloadUiSettings() + input.closeInitialSetup() + if (input.mode === 'required') { + void input.probeRuntime('user') + } +} + export function InitialSetupDialog(): ReactElement { const { t } = useTranslation('settings') const initialSetupMode = useChatStore((s) => s.initialSetupMode) @@ -216,6 +241,8 @@ export function InitialSetupDialog(): ReactElement { }) const [showApiKey, setShowApiKey] = useState(false) const [saving, setSaving] = useState(false) + const [recoveringCredentials, setRecoveringCredentials] = useState(false) + const [credentialRecoveryRequired, setCredentialRecoveryRequired] = useState(false) const [error, setError] = useState(null) const formRef = useRef(null) const isPreview = initialSetupMode === 'preview' @@ -260,9 +287,22 @@ export function InitialSetupDialog(): ReactElement { const handleClose = () => { if (!closeAllowed) return + setSaving(true) setError(null) - closeInitialSetup() - void reloadUiSettings() + void dismissInitialSetup({ + mode: initialSetupMode, + persistCompletion: async () => { + const next = await rendererRuntimeClient.setSettings({ initialSetupCompleted: true }) + emitRendererSettingsChanged(next) + }, + reloadUiSettings, + probeRuntime, + closeInitialSetup + }).catch((e: unknown) => { + setError(e instanceof Error ? e.message : String(e)) + }).finally(() => { + setSaving(false) + }) } const handleOpenKeyPage = (url: string) => { @@ -326,6 +366,7 @@ export function InitialSetupDialog(): ReactElement { const next = await rendererRuntimeClient.setSettings( buildInitialSetupSettingsPatch(current, drafts, selection) ) + setCredentialRecoveryRequired(false) setCurrentForm(next) setDrafts(initialSetupDrafts(next)) emitRendererSettingsChanged(next) @@ -341,12 +382,35 @@ export function InitialSetupDialog(): ReactElement { fallbackRuntimeError: t('common:runtimeFetchFailed') }) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + if (isUnreadableCredentialKeyError(e)) { + setCredentialRecoveryRequired(true) + setError(t('firstRunCredentialRecoveryError')) + } else { + setError(e instanceof Error ? e.message : String(e)) + } } finally { setSaving(false) } } + const handleCredentialReset = async () => { + setRecoveringCredentials(true) + setError(null) + try { + const result = await rendererRuntimeClient.resetUnreadableCredentials() + if (!result.reset) { + setError(t('firstRunCredentialRecoveryError')) + return + } + setCredentialRecoveryRequired(false) + await handleSave() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setRecoveringCredentials(false) + } + } + if (!form || !drafts) { return (
@@ -421,6 +485,7 @@ export function InitialSetupDialog(): ReactElement { + +
+ + ) : null} )} @@ -683,14 +777,15 @@ export function InitialSetupDialog(): ReactElement { ) : null} {onCloseTarget ? ( ) : null} + {isHtmlFile ? ( + + ) : null} + {editableText ? ( + + ) : null} + {editableText ? ( + + ) : null} + {editableText && textDirty ? ( + + ) : null} + + + + + ) : null} + {editingText ? ( +