From 9d443421c5bd5b0f724dfb291b6a09c3bdb1de7c Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Wed, 5 Aug 2026 17:09:26 +0700 Subject: [PATCH 1/2] Hand the Windows office install to the user as a right-click .cmd Field diagnosis with a controlled experiment: Kaspersky Endpoint Security silently kills powershell.exe when it is a child of node.exe running the office setup script - no exception, no crash record, no Defender log - while the IDENTICAL script from the IDENTICAL folder (~/.codev-hub/office) finishes with "Verification passed" when a user launches it in an elevated shell. The trigger is process ancestry, not path or content, and endpoint policy is not changeable in the affected environments. `codevhub skill office` on Windows therefore no longer spawns the installer. It stages the script and bundle as before, writes an Install-CoDev-Office.cmd (Uninstall- for --uninstall) next to them with any --skip-verify/--force-skills flags baked in, opens the folder in Explorer, and prints three steps: right-click, Run as administrator, approve. The .cmd reproduces the verified-good ancestry (explorer -> cmd -> powershell) and ends with `pause` so the closing "Verification passed" (or a [FAIL]) stays on screen for low-tech users. Ubuntu/macOS keep the automatic flow. Co-Authored-By: Claude Fable 5 --- src/lib/office.ts | 93 ++++++++++++++++++++++++++++++-------- tests/lib/download.test.ts | 71 ++++++++++++++++++++++++----- 2 files changed, 134 insertions(+), 30 deletions(-) diff --git a/src/lib/office.ts b/src/lib/office.ts index b457ae2..3bad7aa 100644 --- a/src/lib/office.ts +++ b/src/lib/office.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { chmodSync, mkdirSync, rmSync } from "node:fs"; +import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { OFFICE_DOWNLOADS_URL } from "@/lib/const.js"; import { downloadFile } from "@/lib/download.js"; @@ -196,6 +196,31 @@ function manualRunCommand(platform: OfficePlatform, script: string): string { : `bash ${script}`; } +// Windows installs are handed to the user as a right-click .cmd instead of +// being spawned from codevhub: endpoint protection (Kaspersky Endpoint +// Security in the field) silently kills a powershell child of node.exe +// mid-install, while the very same script from the very same folder finishes +// when the user launches it themselves. The wrapper reproduces that +// verified-good ancestry (explorer -> cmd -> powershell) and needs nothing +// typed. Exported for tests. +export function officeWrapperName(uninstall: boolean): string { + return uninstall ? "Uninstall-CoDev-Office.cmd" : "Install-CoDev-Office.cmd"; +} + +export function officeWrapperContent(script: string, args: string[]): string { + // %~dp0 = the .cmd's own folder; `pause` keeps the window open so the + // closing "Verification passed" (or a [FAIL] line) stays readable. + const argStr = args.map((a) => ` ${a}`).join(""); + return [ + "@echo off", + 'cd /d "%~dp0"', + `powershell -ExecutionPolicy Bypass -File ".\\${script}"${argStr}`, + "echo.", + "pause", + "", + ].join("\r\n"); +} + export function installerArgs( parsed: OfficeArgs, platform: OfficePlatform, @@ -339,6 +364,51 @@ export async function runSkillOffice( chmodSync(join(dir, script), 0o755); } + const scriptArgs = parsed.uninstall + ? uninstallerArgs(parsed, platform) + : installerArgs(parsed, platform); + + // Windows: stage a right-click wrapper and stop — see officeWrapperName. + if (platform === "windows") { + const wrapper = officeWrapperName(parsed.uninstall); + writeFileSync(join(dir, wrapper), officeWrapperContent(script, scriptArgs)); + const verb = parsed.uninstall ? "uninstaller" : "installer"; + console.error(`\nFiles are in ${dir}.`); + console.error( + `codevhub does not auto-run the Windows ${verb}: endpoint protection ` + + "(e.g. Kaspersky) is known to silently kill installers it launches. Instead:", + ); + console.error( + ` 1. Open that folder in File Explorer (opened for you if possible)`, + ); + console.error( + ` 2. Right-click ${wrapper} and choose "Run as administrator"`, + ); + console.error( + ` 3. Approve the prompt and wait for the closing message - the window stays open when done`, + ); + if (hostPlatform === "windows") { + try { + const explorer = spawn("explorer.exe", [dir], { + detached: true, + stdio: "ignore", + }); + // Best-effort convenience: spawn failures surface as an async + // "error" event (which would crash the process if unhandled), + // not as a throw — the printed path is enough either way. + explorer.on("error", () => {}); + explorer.unref(); + } catch { + // Same best-effort stance for synchronous spawn failures. + } + } + logInfo("office windows handoff staged", { + action: parsed.uninstall ? "office.uninstall" : "office.install", + extra: { platform, dir, wrapper }, + }); + return 0; + } + if (downloadOnly) { console.error( `\nFiles are in ${dir}. To ${parsed.uninstall ? "uninstall" : "install"}, run from that folder:`, @@ -346,26 +416,13 @@ export async function runSkillOffice( console.error(` ${manualRunCommand(platform, script)}`); return 0; } - - const scriptArgs = parsed.uninstall - ? uninstallerArgs(parsed, platform) - : installerArgs(parsed, platform); console.error( `\nRunning the ${parsed.uninstall ? "uninstaller" : "installer"} (${script})...\n`, ); - const [command, args] = - platform === "windows" - ? [ - "powershell.exe", - [ - "-ExecutionPolicy", - "Bypass", - "-File", - join(dir, script), - ...scriptArgs, - ], - ] - : ["bash", [join(dir, script), ...scriptArgs]]; + // Windows returned above with the right-click wrapper — only the bash + // platforms reach the spawn. + const command = "bash"; + const args = [join(dir, script), ...scriptArgs]; // cwd = download dir: the scripts locate their bundle zip next to // themselves/CWD, and the staged files are there. const code = await spawner(command, args, dir); diff --git a/tests/lib/download.test.ts b/tests/lib/download.test.ts index 3ae7ba2..23d27a2 100644 --- a/tests/lib/download.test.ts +++ b/tests/lib/download.test.ts @@ -15,6 +15,8 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { downloadFile } from "@/lib/download.js"; import { installerArgs, + officeWrapperContent, + officeWrapperName, runSkillOffice, uninstallerArgs, } from "@/lib/office.js"; @@ -393,6 +395,53 @@ describe("runSkillOffice", () => { expect(existsSync(join(dir, "codev-office-windows.zip"))).toBe(true); }); + test("windows staging writes the right-click wrapper, flags baked in, never spawns", async () => { + // Endpoint protection (KES) silently kills installers spawned by + // codevhub on Windows, so the windows flow must never spawn - it + // stages a .cmd the user runs via right-click -> Run as administrator. + objects.set("/codev-office-windows.zip", BUNDLE); + objects.set("/codev-office-windows-setup.ps1", SCRIPT); + const dir = join(tempDir, "office"); + const spawns: string[] = []; + const code = await runSkillOffice( + [ + "--platform", + "windows", + "--dir", + dir, + "--skip-verify", + "--force-skills", + ], + baseUrl, + async (command) => { + spawns.push(command); + return 0; + }, + ); + expect(code).toBe(0); + expect(spawns).toEqual([]); + const wrapper = readFileSync(join(dir, "Install-CoDev-Office.cmd"), "utf8"); + expect(wrapper).toContain( + 'powershell -ExecutionPolicy Bypass -File ".\\codev-office-windows-setup.ps1" -SkipVerify -ForceSkills', + ); + expect(wrapper).toContain("pause"); + expect(wrapper).toContain('cd /d "%~dp0"'); + }); + + test("wrapper name and content cover the uninstall flow", () => { + expect(officeWrapperName(false)).toBe("Install-CoDev-Office.cmd"); + expect(officeWrapperName(true)).toBe("Uninstall-CoDev-Office.cmd"); + const content = officeWrapperContent("codev-office-windows-uninstall.ps1", [ + "-Yes", + "-SkillsOnly", + ]); + expect(content).toContain( + 'powershell -ExecutionPolicy Bypass -File ".\\codev-office-windows-uninstall.ps1" -Yes -SkillsOnly', + ); + // CRLF line endings - the file must open cleanly in cmd.exe. + expect(content).toContain("\r\n"); + }); + test("always refetches the setup script, but reuses a finished bundle", async () => { const dir = join(tempDir, "office"); mkdirSync(dir, { recursive: true }); @@ -428,7 +477,11 @@ describe("runSkillOffice", () => { // The PowerShell branch is unreachable from a non-Windows host — a // cross-platform --platform forces download-only — so the host is stubbed to // keep the argv shape pinned on the Linux/macOS machines that run this suite. - test("runs the PowerShell installer on a Windows host", async () => { + test("a Windows host stages the right-click wrapper instead of spawning", async () => { + // Endpoint protection (KES in the field) silently kills a powershell + // child of node.exe mid-install, so the installer must never be + // spawned from codevhub on Windows — the user launches the staged + // .cmd via right-click -> Run as administrator instead. objects.set("/codev-office-windows.zip", BUNDLE); objects.set("/codev-office-windows-setup.ps1", SCRIPT); const dir = join(tempDir, "office"); @@ -444,17 +497,11 @@ describe("runSkillOffice", () => { ), ); expect(code).toBe(0); - expect(spawned).toEqual({ - command: "powershell.exe", - args: [ - "-ExecutionPolicy", - "Bypass", - "-File", - join(dir, "codev-office-windows-setup.ps1"), - "-SkipVerify", - ], - cwd: dir, - }); + expect(spawned).toBeNull(); + const wrapper = readFileSync(join(dir, "Install-CoDev-Office.cmd"), "utf8"); + expect(wrapper).toContain( + 'powershell -ExecutionPolicy Bypass -File ".\\codev-office-windows-setup.ps1" -SkipVerify', + ); }); test("exits 1 on an OS with no bundle and downloads nothing", async () => { From c2249bfa14f3a121d9c2da6259ef5b81837008ac Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Wed, 5 Aug 2026 17:14:36 +0700 Subject: [PATCH 2/2] Make the Windows install wrapper self-elevating with a non-admin fallback Some environments strip "Run as administrator" from the context menu, and some users have no admin rights at all. The staged .cmd now handles both from a plain double-click: - not elevated -> relaunches itself elevated via a UAC prompt (Start-Process -Verb RunAs) - elevation declined or unavailable -> continues non-elevated, which the setup script explicitly supports: each component installer raises its own permission prompt, and a declined one only skips that component (fatal for the .NET SDK alone) The printed steps now say double-click instead of right-click, with the no-admin-rights path spelled out. Co-Authored-By: Claude Fable 5 --- src/lib/office.ts | 28 ++++++++++++++++++++++++---- tests/lib/download.test.ts | 7 +++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/lib/office.ts b/src/lib/office.ts index 3bad7aa..a099a3d 100644 --- a/src/lib/office.ts +++ b/src/lib/office.ts @@ -208,12 +208,28 @@ export function officeWrapperName(uninstall: boolean): string { } export function officeWrapperContent(script: string, args: string[]): string { - // %~dp0 = the .cmd's own folder; `pause` keeps the window open so the - // closing "Verification passed" (or a [FAIL] line) stays readable. + // Self-elevating so a plain DOUBLE-CLICK is enough (some environments strip + // "Run as administrator" from the context menu): when not elevated, the + // .cmd relaunches itself elevated via a UAC prompt. If elevation is + // declined or unavailable (non-admin account), it continues non-elevated — + // the setup script supports that: each component installer raises its own + // permission prompt, and a declined one only skips that component (fatal + // for the .NET SDK alone). + // %~dp0 = the .cmd's own folder (an elevated relaunch starts in System32); + // `pause` keeps the window open so the closing "Verification passed" (or a + // [FAIL] line) stays readable. const argStr = args.map((a) => ` ${a}`).join(""); return [ "@echo off", 'cd /d "%~dp0"', + "net session >nul 2>&1", + "if not errorlevel 1 goto :run", + "echo Requesting administrator rights - choose Yes in the prompt...", + "powershell -NoProfile -Command \"Start-Process -FilePath '%~f0' -Verb RunAs\" >nul 2>&1", + "if not errorlevel 1 exit /b 0", + "echo Continuing without administrator rights - each installer will ask for permission separately.", + "echo.", + ":run", `powershell -ExecutionPolicy Bypass -File ".\\${script}"${argStr}`, "echo.", "pause", @@ -381,11 +397,15 @@ export async function runSkillOffice( console.error( ` 1. Open that folder in File Explorer (opened for you if possible)`, ); + console.error(` 2. Double-click ${wrapper}`); + console.error( + ` 3. Choose "Yes" when Windows asks for administrator permission`, + ); console.error( - ` 2. Right-click ${wrapper} and choose "Run as administrator"`, + ` (no admin rights? choose "No" - the install continues and each component asks for permission separately)`, ); console.error( - ` 3. Approve the prompt and wait for the closing message - the window stays open when done`, + ` 4. Wait for the closing message - the window stays open when done`, ); if (hostPlatform === "windows") { try { diff --git a/tests/lib/download.test.ts b/tests/lib/download.test.ts index 23d27a2..492d444 100644 --- a/tests/lib/download.test.ts +++ b/tests/lib/download.test.ts @@ -426,6 +426,13 @@ describe("runSkillOffice", () => { ); expect(wrapper).toContain("pause"); expect(wrapper).toContain('cd /d "%~dp0"'); + // Self-elevation: a plain double-click must request admin rights itself + // (some environments strip "Run as administrator" from the context + // menu) and fall back to a non-elevated run when declined. + expect(wrapper).toContain("net session"); + expect(wrapper).toContain("Start-Process -FilePath '%~f0' -Verb RunAs"); + expect(wrapper).toContain(":run"); + expect(wrapper).toContain("Continuing without administrator rights"); }); test("wrapper name and content cover the uninstall flow", () => {