From b9123432c5c8a4fbdd968fa8c35eaaa19297437d Mon Sep 17 00:00:00 2001 From: linnnn89 <216342082+linnnn89@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:35:40 +0800 Subject: [PATCH] fix: launch Repomix without shell interpretation --- CHANGELOG.md | 6 ++ CONTRIBUTING.md | 2 + README.md | 4 +- docs/codex_worklog.md | 11 +++ package-lock.json | 4 +- package.json | 2 +- skills/wincode/SKILL.md | 2 +- skills/wincode/references/diagnostics.md | 2 + src/Adapters/RepomixAdapter.ts | 71 ++++++++++++-- src/Core/Config.ts | 3 +- tests/repomix-disabled.test.ts | 96 +++++++++++++++++++ tests/tdd-suite.test.ts | 31 ++++-- .../WinCode.UIA.Host/WinCode.UIA.Host.csproj | 2 +- 13 files changed, 210 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f0e94..a4222b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.12.4 + +- Repomix health and packing launch an installed JavaScript bin directly with the current Node executable and separate arguments, eliminating the cmd/npx shell chain. Local package bin discovery and explicit absolute customCliPath are supported; shell wrappers and npx caches are no longer invoked. Missing or invalid entries use the builtin packer without installing anything. +- Real child-process regression fixtures cover Unicode, spaces and shell metacharacters in paths/arguments, missing or invalid overrides, local package discovery, timeout and cancellation with PID exit checks. These validate the launcher contract, not an installed upstream Repomix release. +- main protection now requires pull requests, the Node 22/24 regression matrix and all three CodeQL analysis checks, including administrators; force pushes and branch deletion are disabled. Independent approval is not required by GitHub for this single-maintainer workflow. + ## 0.12.3 - Add an opt-in six-task TavernDesk acceptance entry point. Discover candidate files with bounded native searches, locate unique live navigation controls, and verify command assignments and method declaration snippets against current source hashes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e325ad..80b84d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,4 +25,6 @@ For a version change, align `package.json`, the root entries in `package-lock.js Before merging a topic branch, record the problem, changes, failures and actual verification in the existing work log. Require successful Node 22/24 and CodeQL results for the exact PR head. Keep independent review and author self-review distinct, and identify unverified integrations. Repository branch-protection settings require a separate owner decision; this document does not establish enforced protection. A successful CodeQL run also does not prove existing alerts are closed. +On 2026-09-08, the owner authorized applying main protection. The read-back confirmed required pull requests, strict Node 22/24 regression and three CodeQL analysis checks bound to GitHub Actions, enforcement for administrators, resolved conversations, and disabled force pushes/deletions. The single-maintainer policy requires zero GitHub approvals; this does not constitute an independent review. Re-read GitHub settings when verifying current enforcement. + SDK policy follows [Microsoft global.json guidance](https://learn.microsoft.com/en-us/dotnet/core/tools/global-json); dependency locking uses [NuGet locked restore](https://learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files#locking-dependencies). diff --git a/README.md b/README.md index 82b2ab7..79ae25e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ WinCode is a local MCP server built for Windows and .NET engineering. It bridges - **Inspect the running app:** Enumerate visible windows, query specific controls or subtrees, and capture numbered visual overlays without activating or stealing focus from the target. - **Review with evidence:** Trace on-screen widgets back to literal XAML declaration tags, line numbers, and file hashes, with transparent reporting for ambiguity, truncation, or degraded upstreams. -Current source version: **0.12.3**. All UI tools are strictly read-only and non-destructive. See [CHANGELOG](CHANGELOG.md) for full version history. +Current source version: **0.12.4**. All UI tools are strictly read-only and non-destructive. See [CHANGELOG](CHANGELOG.md) for full version history. ### Quick start @@ -211,7 +211,7 @@ WinCode 是面向 Windows 与 .NET 工程研发的本地 MCP 服务。它将项 - **观察实际界面:**发现系统可见窗口,按条件定向查询目标控件或子树,并在不激活、不抢占前台焦点的前提下获取数字标注截图。 - **源码双向印证:**将运行时抓取的控件关联回 XAML 源码声明的起始行号、代码片段与文件哈希,清晰报告歧义、截断与降级状态。 -当前源码版本为 **0.12.3**。所有 UI 取证工具均为纯只读与非侵入设计。版本历史见 [CHANGELOG](CHANGELOG.md)。 +当前源码版本为 **0.12.4**。所有 UI 取证工具均为纯只读与非侵入设计。版本历史见 [CHANGELOG](CHANGELOG.md)。 ### 快速上手 diff --git a/docs/codex_worklog.md b/docs/codex_worklog.md index 68d3bae..a284fcb 100644 --- a/docs/codex_worklog.md +++ b/docs/codex_worklog.md @@ -484,3 +484,14 @@ - 临时探针曾调用不存在的 connectTransport(失败日志 wp5-user-card-probe.log),核对既有 runtime-contract 测试后改为公开 SDK InMemoryTransport 与 Gateway 内已有 server.connect,复测通过;没有为测试添加生产连接 API。头像初检把相对路径误按 WinCode cwd 解析,随后查明 AppDataPaths 的 data 根规则并正确复核;初检 false 不属于应用文件丢失。 - 为让远端检查报告直接提供实际测试数量,check 使用 Node 内置 TAP reporter 并收集 tests/pass/fail/cancelled/skipped 摘要;不只留下“命令退出 0”。缺少完整测试摘要仍报错,不把未运行套件记为通过。 - TAP 报告补充实测:核心 check 通过,report.tests={tests:301,pass:300,fail:0,cancelled:0,skipped:1}(2026-09-08T13-05-15-874Z-core);桌面 check 通过,report.tests={tests:35,pass:35,fail:0,cancelled:0,skipped:0}(2026-09-08T13-06-36-861Z-desktop)。更新 PR 后重新核对精确 head CI,不沿用先前 head 的成功状态。 + +## 2026-09-08 21:37 — 0.12.4 安全启动与剩余验收 + +- 用户要求尝试落实四项检查,按已说明方案推进 F12/D3;随后明确批准 Serena 隔离安装与验收,上限新增磁盘 1 GB、安装 15 分钟,不改全局 PATH。 +- main 保护已通过 GitHub API 写入并回读:PR 必需、strict Node 22/24 与 CodeQL csharp/javascript-typescript/actions 五项检查,绑定 GitHub Actions app=15368;enforce_admins=true、禁止 force push/删除、要求解决讨论。单维护者 required_approving_review_count=0,不将其写作独立审核。请求/回执在 test-tmp/security-verification/main-protection-*.json;本轮未新增子代理。 +- Repomix 健康与打包改为 process.execPath+独立 argv+shell:false。读取本地 package.bin,显式 customCliPath 仅接受绝对 JS 入口;不再调用 npx 缓存/PATH shell 包装器,不安装 Repomix。保留禁用无进程、降级、缓存策略隔离;随机输出名避免并发碰撞,清理超时文件和取消监听器,持续排空并限制输出。 +- 初次新增测试错误使用 CacheManager 构造参数/dispose,修正为现有 API 后 15/15 通过;这些是真实 Node 子进程夹具,不是真实 Repomix 包验收。覆盖特殊字符 argv、安装 bin 发现、无效显式路径、超时与取消后 PID 退出。失败证据保留 repomix-targeted.log。 +- 完整检查第一轮暴露两项旧测试依赖 npx 恰好较慢;改为真实挂起脚本,不削弱超时/清理断言。第二轮因隔离 Serena 安装扩大工作区,旧全仓影响分析触发真实 total-byte-limit 并正确返回 UNKNOWN;将该测试改为固定两文件夹具,保留有引用风险评估及置信度约束,未放宽生产扫描上限。第三轮完整 check 通过,报告/日志见 test-tmp/security-verification/full-check-3.log。 +- Serena 固定 v1.7.0 commit=949a27ef1e5fda1a6e7b561e777bcece345c6ffd,复用现有 Python 3.13.7,venv/uv-cache/SERENA_HOME/语言服务位于 test-tmp/serena-real;安装完成约 632 MB,.NET 10 复用已有环境。C# Roslyn=5.5.0-2.26078.4,下载校验采用上游固定 SHA-256。握手真实成功,第一次查询发现 structuredContent.result 包裹 JSON 字符串被当成符号对象;上游 content 内实际返回三个完整身份。此新缺陷作为后续兼容修复,不把本 PR 写成 Serena 已验收。 +- 当前 Codex 管理 CLI 只有 list/get/add/remove/login/logout,没有受支持的 reconnect 命令。保存配置指向正确 I:/WinCode/dist/index.js;没有用重复注册或杀进程替代重连,也未宣称旧父连接更新。 +- 作者反证自审:无 shell 不等于已安装脚本可信或有沙盒;缺失显式 CLI 不应悄悄执行另一安装。CodeQL 任务成功仍需合并后读取 alert #1 的 fixed 状态;PR/远端验收待后续回执。 diff --git a/package-lock.json b/package-lock.json index 789db27..9c0cfb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wincode-mcp", - "version": "0.12.3", + "version": "0.12.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wincode-mcp", - "version": "0.12.3", + "version": "0.12.4", "license": "MIT", "dependencies": { "@modelcontextprotocol/client": "2.0.0", diff --git a/package.json b/package.json index 0871380..27cd3e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wincode-mcp", - "version": "0.12.3", + "version": "0.12.4", "description": "Windows-first MCP gateway: .NET project graph, evidence-bounded context, honest change-impact, long-running process hygiene", "main": "dist/index.js", "type": "module", diff --git a/skills/wincode/SKILL.md b/skills/wincode/SKILL.md index 0415b0f..454bb6f 100644 --- a/skills/wincode/SKILL.md +++ b/skills/wincode/SKILL.md @@ -5,7 +5,7 @@ description: 使用 WinCode MCP 分析 Windows/.NET 工作区,或读取桌面 # WinCode -仓库手册版本:0.12.3。安装内容可用 `node scripts/sync-skill.mjs <安装目录绝对路径>` 核对;仅维护时执行,不在每个任务中例行检查。以当前连接实际 Schema 为准,手册版本不证明 MCP 已重连。 +仓库手册版本:0.12.4。安装内容可用 `node scripts/sync-skill.mjs <安装目录绝对路径>` 核对;仅维护时执行,不在每个任务中例行检查。以当前连接实际 Schema 为准,手册版本不证明 MCP 已重连。 仅按当前任务读取对应手册,不预读全部文件: - 代码、上下文、引用、影响分析:[code](references/code.md)。 diff --git a/skills/wincode/references/diagnostics.md b/skills/wincode/references/diagnostics.md index 2fee6f1..bf1f79e 100644 --- a/skills/wincode/references/diagnostics.md +++ b/skills/wincode/references/diagnostics.md @@ -1,5 +1,7 @@ # 诊断与审计 +从 0.12.4 起 Repomix 健康探测和打包都由当前 Node 可执行文件直接启动已安装的 JavaScript CLI;不经过 cmd、npx 或 PATH 包装脚本,也不下载包。默认按目标工作区和 WinCode 安装目录的 Node 模块路径读取 repomix/package.json 的 bin 入口;不搜索 npx 缓存或 npm 自定义全局前缀。非标准安装需在宿主 WinCodeConfig.adapters.repomix.customCliPath 提供绝对 .js/.cjs/.mjs 路径;该字段不是 MCP 工具参数,不能传给 hello/prepare_context。显式路径无效时返回 builtin fallback,不执行另一份安装;useCli=false 仍完全禁止探测和启动。执行已安装脚本不提供沙盒或脚本可信性保证。 + `hello` 从 0.12.1 起只读取版本、能力和已知状态,不启动上游、CLI 或 UI Host 探测进程。`health.healthObservation` 区分 `known/unknown` 并给出 `observedAt`;`unknown`、`available:null` 或 `commandFound:null` 表示尚未探测,不能解释为不可用。配置禁用属于已知策略,但观察时间可为 null。已知健康结果可能陈旧,需要当前检查时调用现有 `wincode_diagnose_project({})`,不向 hello 添加未声明的 force/probe 字段。 代码查询、引用、上下文、影响分析和重构建议接收 MCP 客户端取消信号;停止后续扫描/打包,等待当前读操作或自有上游进程清理后释放请求占用。上游 RPC 取消可能重置共享 Serena 连接,其他上游调用可能失败或降级;不保证外部服务器的单请求取消实现。磁盘单次 OS I/O 不能保证瞬时中断。工作区切换在等待和提交前可取消;已开始提交切换时完成一致性收尾,不声称已回滚。 diff --git a/src/Adapters/RepomixAdapter.ts b/src/Adapters/RepomixAdapter.ts index c158e16..7ba9d44 100644 --- a/src/Adapters/RepomixAdapter.ts +++ b/src/Adapters/RepomixAdapter.ts @@ -3,6 +3,7 @@ import { spawn, ChildProcess } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; import crypto from 'node:crypto'; +import { createRequire } from 'node:module'; import { IAdapter, AdapterHealth, AdapterLastError } from './IAdapter.js'; import { WinCodeConfig, getDefaultTimeouts } from '../Core/Config.js'; import { CacheManager } from '../Core/Cache.js'; @@ -20,6 +21,7 @@ export class RepomixAdapter implements IAdapter { private cache: CacheManager; private resources?: ResourceManager; private isCliAvailable = false; + private cliEntry: string | null = null; private activeProcesses: Set = new Set(); private healthCache: { at: number; value: AdapterHealth } | null = null; private inflightPacks = new Map>(); @@ -52,6 +54,7 @@ export class RepomixAdapter implements IAdapter { // Configuration is authoritative even when an earlier probe found an installed CLI. if (!this.config.adapters.repomix.useCli) { this.isCliAvailable = false; + this.cliEntry = null; this.healthCache = null; return { available: true, @@ -66,11 +69,22 @@ export class RepomixAdapter implements IAdapter { return this.healthCache.value; } + this.cliEntry = await this.resolveCliEntry(); + if (!this.config.adapters.repomix.useCli) return this.checkHealth(timeoutMs); + if (!this.cliEntry) { + this.isCliAvailable = false; + const health: AdapterHealth = { available: true, source: 'fallback', + details: 'Repomix JavaScript CLI entry not found; using builtin packer. Install locally or configure an absolute customCliPath (.js/.cjs/.mjs); npx caches and shell wrappers are not executed.' }; + this.healthCache = { at: Date.now(), value: health }; + return health; + } const health = await new Promise((resolve) => { let isSettled = false; - const proc = spawn('cmd', ['/c', 'npx --no-install repomix --version'], { + const proc = spawn(process.execPath, [this.cliEntry!, '--version'], { cwd: this.config.workspaceRoot, windowsHide: true, + shell: false, + stdio: ['ignore', 'pipe', 'ignore'], }); this.trackProcess(proc); @@ -95,7 +109,7 @@ export class RepomixAdapter implements IAdapter { }, waitMs); let stdout = ''; - proc.stdout?.on('data', (d) => (stdout += d.toString())); + proc.stdout?.on('data', (d) => { stdout = (stdout + d.toString()).slice(0, 4096); }); proc.on('close', (code) => { if (isSettled) return; @@ -140,9 +154,43 @@ export class RepomixAdapter implements IAdapter { }); this.healthCache = { at: Date.now(), value: health }; + this.isCliAvailable = health.source === 'installed'; return health; } + private async resolveCliEntry(): Promise { + const validEntry = async (entry: string): Promise => { + if (!path.isAbsolute(entry) || !/\.(?:cjs|mjs|js)$/i.test(entry)) return null; + return await fs.stat(entry).then(stat => stat.isFile() ? entry : null).catch(() => null); + }; + const custom = this.config.adapters.repomix.customCliPath; + // An invalid explicit override must not silently launch a different installation. + if (custom !== undefined) return validEntry(custom); + const searchPaths = new Set([ + ...(createRequire(path.join(this.config.workspaceRoot, 'package.json')).resolve.paths('repomix') ?? []), + ...(createRequire(import.meta.url).resolve.paths('repomix') ?? []), + ]); + for (const base of searchPaths) { + const packageRoot = path.join(base, 'repomix'); + try { + const manifestPath = path.join(packageRoot, 'package.json'); + if ((await fs.stat(manifestPath)).size > 64 * 1024) continue; + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.repomix; + if (manifest.name !== 'repomix' || typeof bin !== 'string' || path.isAbsolute(bin)) continue; + const entry = path.resolve(packageRoot, bin); + const relative = path.relative(packageRoot, entry); + if (relative.startsWith('..') || path.isAbsolute(relative)) continue; + const realRoot = await fs.realpath(packageRoot); + const realEntry = await fs.realpath(entry); + const realRelative = path.relative(realRoot, realEntry); + if (realRelative.startsWith('..') || path.isAbsolute(realRelative)) continue; + if (await validEntry(realEntry)) return realEntry; + } catch { /* Missing or malformed packages leave the builtin packer available. */ } + } + return null; + } + private trackProcess(proc: ChildProcess): void { this.activeProcesses.add(proc); this.resources?.registerProcess('repomix', proc); @@ -160,7 +208,7 @@ export class RepomixAdapter implements IAdapter { // A disabled request must neither read a CLI snapshot nor join an enabled CLI pack. const allowCli = this.config.adapters.repomix.useCli; const policy = allowCli ? 'cli-enabled' : 'builtin-only'; - const cacheKey = `repomix_pack_v3_${policy}_${JSON.stringify(options || {})}_${this.config.workspaceRoot}`; + const cacheKey = `repomix_pack_v4_${policy}_${this.config.adapters.repomix.customCliPath ?? ''}_${JSON.stringify(options || {})}_${this.config.workspaceRoot}`; const fingerprint = await this.cache.computeWorkspaceFingerprint(this.config.workspaceRoot); const cached = await this.cache.get(cacheKey, fingerprint); @@ -228,13 +276,12 @@ export class RepomixAdapter implements IAdapter { if (!this.config.adapters.repomix.useCli) return this.packWithFallback(options, operation); const ext = style === 'xml' ? 'xml' : 'md'; - const tempOutputFile = path.join(tempOutputDir, `repomix_${Date.now()}.${ext}`); + const tempOutputFile = path.join(tempOutputDir, `repomix_${crypto.randomUUID()}.${ext}`); + const entry = this.cliEntry; + if (!entry) throw new Error('Repomix CLI entry has not passed its health check'); const args = [ - '/c', - 'npx', - '--no-install', - 'repomix', + entry, '--style', style, '-o', @@ -253,15 +300,17 @@ export class RepomixAdapter implements IAdapter { return new Promise((resolve, reject) => { let isSettled = false; - const proc = spawn('cmd', args, { + const proc = spawn(process.execPath, args, { cwd: root, windowsHide: true, + shell: false, + stdio: ['ignore', 'ignore', 'pipe'], }); this.trackProcess(proc); let stderr = ''; - proc.stderr?.on('data', (d) => (stderr += d.toString())); + proc.stderr?.on('data', (d) => { stderr = (stderr + d.toString()).slice(-4096); }); const packTimeoutMs = this.config.timeouts?.repomixPackMs ?? 30_000; const cancel = async () => { @@ -279,8 +328,10 @@ export class RepomixAdapter implements IAdapter { const timeout = setTimeout(async () => { if (isSettled) return; isSettled = true; + operation?.signal?.removeEventListener('abort', cancel); this.untrackProcess(proc); await killProcessTree(proc).catch(() => {}); + await fs.unlink(tempOutputFile).catch(() => {}); reject(new TimeoutError('repomix', packTimeoutMs)); }, packTimeoutMs); operation?.signal?.addEventListener('abort', cancel, { once: true }); diff --git a/src/Core/Config.ts b/src/Core/Config.ts index c1a8578..12e468b 100644 --- a/src/Core/Config.ts +++ b/src/Core/Config.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -export const WINCODE_VERSION = '0.12.3'; +export const WINCODE_VERSION = '0.12.4'; /** * Bounded waits for every external process/RPC. None of these may be Infinity. @@ -41,6 +41,7 @@ export interface WinCodeConfig { adapters: { repomix: { useCli: boolean; + /** Absolute installed JavaScript CLI entry (.js/.cjs/.mjs), never a shell wrapper. */ customCliPath?: string; }; serena: { diff --git a/tests/repomix-disabled.test.ts b/tests/repomix-disabled.test.ts index 8b9f19f..003f573 100644 --- a/tests/repomix-disabled.test.ts +++ b/tests/repomix-disabled.test.ts @@ -26,6 +26,8 @@ async function fixture(run: (adapter: RepomixAdapter, config: ReturnType, root: string) => Promise) { + // The metacharacters are legal directory characters, not an actual shell payload. + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wincode 中文 & %PATH% ^ (cli) ')); + const config = getDefaultConfig(root); + const script = path.join(root, 'tool & 中文.cjs'); + config.adapters.repomix.customCliPath = script; + const cache = new CacheManager(config.cacheDir); + const adapter = new RepomixAdapter(config, cache); + await fs.writeFile(path.join(root, 'Example.ts'), 'export const builtinEvidence = true;'); + await fs.writeFile(script, ` +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +if (args.includes('--version')) { console.log('fixture-1'); process.exit(0); } +fs.writeFileSync(path.join(process.cwd(), 'started.json'), JSON.stringify({pid:process.pid,args,cwd:process.cwd()})); +if (args.includes('--compress')) { setInterval(() => {}, 1000); } +else fs.writeFileSync(args[args.indexOf('-o') + 1], JSON.stringify({args,cwd:process.cwd()})); +`); + try { await run(adapter, config, root); } + finally { + await adapter.dispose(); + assert.equal(path.dirname(root), os.tmpdir()); + await fs.rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); + } +} + +it('real Node CLI receives exact special-character paths and include argv without a shell', async () => realCliFixture(async (adapter, config, root) => { + await adapter.initialize(); + assert.equal((await adapter.checkHealth()).source, 'installed'); + const include = 'src/中文 & %PATH% ^ (x),"quote";echo'; + const result = await adapter.packWorkspace({ include: [include] }); + assert.equal(result.source, 'repomix-cli'); + const received = JSON.parse(result.content); + assert.equal(received.cwd, root); + assert.equal(received.args[received.args.indexOf('--include') + 1], include); + const output = received.args[received.args.indexOf('-o') + 1]; + assert.equal(path.dirname(output), path.join(config.cacheDir, 'repomix_tmp')); + await assert.rejects(fs.stat(output), { code: 'ENOENT' }); + assert.equal(adapter.activeProcessCount, 0); +})); + +it('discovers installed package bin metadata without invoking npm or PATH wrappers', async () => realCliFixture(async (adapter, config, root) => { + const packageRoot = path.join(root, 'node_modules', 'repomix'); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.copyFile(config.adapters.repomix.customCliPath!, path.join(packageRoot, 'cli.cjs')); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ name: 'repomix', bin: { repomix: './cli.cjs' } })); + delete config.adapters.repomix.customCliPath; + await adapter.initialize(); + assert.equal((await adapter.packWorkspace()).source, 'repomix-cli'); +})); + +for (const invalid of ['missing.cjs', 'wrapper.cmd', 'relative.cjs']) { + it(`invalid explicit CLI ${invalid} falls back without a process`, async () => realCliFixture(async (adapter, config, root) => { + await fs.writeFile(path.join(root, 'wrapper.cmd'), '@echo should-never-run'); + config.adapters.repomix.customCliPath = invalid === 'relative.cjs' ? invalid : path.join(root, invalid); + const probe = fakeCliProbe(); + try { + await adapter.initialize(); + assert.equal((await adapter.checkHealth()).source, 'fallback'); + assert.equal((await adapter.packWorkspace()).source, 'builtin-fallback'); + assert.equal(probe.count(), 0); + } finally { probe.restore(); } + })); +} + +it('timed out real CLI falls back after its process exits', async () => realCliFixture(async (adapter, config, root) => { + config.timeouts.repomixPackMs = 1500; + await adapter.initialize(); + const result = await adapter.packWorkspace({ compress: true }); + assert.equal(result.source, 'builtin-fallback'); + assert.equal(adapter.lastError?.reason, 'timeout'); + const { pid } = JSON.parse(await fs.readFile(path.join(root, 'started.json'), 'utf8')); + assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' }); + assert.equal(adapter.activeProcessCount, 0); +})); + +it('cancelling real CLI rejects and waits for its process to exit', async () => realCliFixture(async (adapter, _config, root) => { + await adapter.initialize(); + const controller = new AbortController(); + const running = adapter.packWorkspace({ compress: true }, { signal: controller.signal }); + const rejection = assert.rejects(running, error => error instanceof Error && /abort|cancel/i.test(error.message)); + let started: { pid: number } | undefined; + for (let attempt = 0; attempt < 100; attempt++) { + started = await fs.readFile(path.join(root, 'started.json'), 'utf8').then(JSON.parse).catch(() => undefined); + if (started) break; + await new Promise(resolve => setTimeout(resolve, 25)); + } + controller.abort(); + await rejection; + assert.ok(started, 'fixture process reached its packing code'); + assert.throws(() => process.kill(started.pid, 0), { code: 'ESRCH' }); + assert.equal(adapter.activeProcessCount, 0); +})); + it('disabled configuration overrides an earlier installed health memo', async () => { const probe = fakeCliProbe(); try { diff --git a/tests/tdd-suite.test.ts b/tests/tdd-suite.test.ts index 25016fb..5102766 100644 --- a/tests/tdd-suite.test.ts +++ b/tests/tdd-suite.test.ts @@ -615,7 +615,11 @@ describe('WinCode MCP Comprehensive TDD Test Suite', () => { }); it('Resilience: checkHealth should respect timeout, terminate hung process tree, and gracefully fallback', async () => { - const slowRepomix = new RepomixAdapter(config, cache); + const cli = path.join(testCacheDir, 'hung-health.cjs'); + await fs.writeFile(cli, 'setInterval(() => {}, 1000);'); + const isolatedConfig = structuredClone(config); + isolatedConfig.adapters.repomix.customCliPath = cli; + const slowRepomix = new RepomixAdapter(isolatedConfig, cache); const startTime = Date.now(); // Test with a tiny timeout (50ms) to ensure timeout handling kicks in without hanging @@ -623,22 +627,25 @@ describe('WinCode MCP Comprehensive TDD Test Suite', () => { const elapsed = Date.now() - startTime; assert.ok(health.available, 'Should be marked available'); - // Either it finishes immediately if cached/fast or times out and falls back - if (health.source === 'fallback') { - assert.ok(health.details?.includes('timed out') || health.details?.includes('built-in')); - } + assert.strictEqual(health.source, 'fallback'); + assert.strictEqual(slowRepomix.lastError?.reason, 'timeout'); assert.ok(elapsed < 2000, `Health check must not block, took ${elapsed}ms`); assert.strictEqual(slowRepomix.activeProcessCount, 0, 'Active process count must be 0 after completion or timeout'); }); it('Process Management & Dispose: should terminate all active child process trees on dispose()', async () => { - const managedRepomix = new RepomixAdapter(config, cache); + const cli = path.join(testCacheDir, 'dispose-health.cjs'); + await fs.writeFile(cli, 'setInterval(() => {}, 1000);'); + const isolatedConfig = structuredClone(config); + isolatedConfig.adapters.repomix.customCliPath = cli; + const managedRepomix = new RepomixAdapter(isolatedConfig, cache); // Start a long-running child process simulated via checkHealth with large timeout const healthPromise = managedRepomix.checkHealth(15000); // Give it a few ms to spawn the child process - await new Promise((r) => setTimeout(r, 100)); + for (let attempts = 0; attempts < 100 && managedRepomix.activeProcessCount === 0; attempts++) + await new Promise((r) => setTimeout(r, 20)); assert.ok(managedRepomix.activeProcessCount >= 1, 'Should track active child process'); @@ -731,7 +738,15 @@ describe('WinCode MCP Comprehensive TDD Test Suite', () => { }); it('ImpactAnalyzer should calculate blast radius and correct risk level', async () => { - const impact = await router.impact.analyzeImpact('ToolRouter'); + // A growing checkout (including optional upstream installs) is not a bounded test fixture. + const impactRoot = path.join(testCacheDir, 'impact-fixture'); + await fs.mkdir(impactRoot, { recursive: true }); + await fs.writeFile(path.join(impactRoot, 'ToolRouter.ts'), 'export class ToolRouter {}'); + await fs.writeFile(path.join(impactRoot, 'Caller.ts'), 'import { ToolRouter } from "./ToolRouter";\nexport const caller = new ToolRouter();'); + const impactConfig = getDefaultConfig(impactRoot); + impactConfig.adapters.serena.enabled = false; + const impactSerena = new SerenaAdapter(impactConfig, new CacheManager(path.join(testCacheDir, 'impact-cache'))); + const impact = await new ImpactAnalyzer(impactSerena, impactConfig).analyzeImpact('ToolRouter'); assert.strictEqual(impact.target, 'ToolRouter'); assert.ok(impact.targetFile.includes('ToolRouter.ts')); assert.ok( diff --git a/tools/WinCode.UIA.Host/WinCode.UIA.Host.csproj b/tools/WinCode.UIA.Host/WinCode.UIA.Host.csproj index 051fccb..61bb561 100644 --- a/tools/WinCode.UIA.Host/WinCode.UIA.Host.csproj +++ b/tools/WinCode.UIA.Host/WinCode.UIA.Host.csproj @@ -1,7 +1,7 @@ - 0.12.3 + 0.12.4 true Exe net10.0-windows