diff --git a/package.json b/package.json index 5772110..5960bc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codev-ai", - "version": "0.5.11", + "version": "0.5.12", "description": "CoDev — AI Coding Agent Hub. Install, configure, and manage multiple AI coding agents.", "keywords": [ "ai", diff --git a/src/lib/office.ts b/src/lib/office.ts index dfd54f1..45a34b6 100644 --- a/src/lib/office.ts +++ b/src/lib/office.ts @@ -6,9 +6,7 @@ import { readdirSync, renameSync, rmSync, - writeFileSync, } from "node:fs"; -import { homedir } from "node:os"; import { join } from "node:path"; import { OFFICE_DOWNLOADS_URL } from "@/lib/const.js"; import { downloadFile } from "@/lib/download.js"; @@ -205,31 +203,17 @@ 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"; -} - -// Paths baked into the wrapper so a UAC elevation with a DIFFERENT admin -// account cannot strand the install on the admin's profile: the JS module -// tree goes to the shared, account-independent %PUBLIC% dir, and the skills -// root is pinned to the REAL user's profile — codevhub runs unelevated as -// that user, so homedir() is authoritative here. Cross-platform staging -// (--platform windows from another OS) cannot know the target machine's -// user, so only the modules dir is baked there. Exported for tests. -export function officeWrapperBakedArgs(hostIsWindows: boolean): string[] { - const publicDir = process.env.PUBLIC ?? "C:\\Users\\Public"; - const args = ["-ModulesDir", `${publicDir}\\codev-office\\node_modules`]; - if (hostIsWindows) { - args.push("-SkillsRoot", `${homedir()}\\.config\\codev\\skills`); - } - return args; +// The exact command the user types in an ELEVATED PowerShell — after field +// testing every alternative launcher, this is the only launch mode the +// endpoint protection tolerates: anything codevhub starts (directly or via a +// staged launcher) gets killed or quarantined, while the same command typed +// interactively runs to "Verification passed". Exported for tests. +export function officeManualWindowsCommand( + script: string, + args: string[], +): string { + const argStr = args.map((a) => ` ${/\s/.test(a) ? `"${a}"` : a}`).join(""); + return `powershell -ExecutionPolicy Bypass -File .\\${script}${argStr}`; } // One-time migration: move files staged under the old per-user dot-folder @@ -271,36 +255,6 @@ export function migrateLegacyOfficeDir(fromDir: string, toDir: string): void { } } -export function officeWrapperContent(script: string, args: string[]): string { - // 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) => ` ${/\s/.test(a) ? `"${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", - "", - ].join("\r\n"); -} - export function installerArgs( parsed: OfficeArgs, platform: OfficePlatform, @@ -461,53 +415,37 @@ export async function runSkillOffice( ? uninstallerArgs(parsed, platform) : installerArgs(parsed, platform); - // Windows: stage a right-click wrapper and stop — see officeWrapperName. + // Windows: never launch the installer from codevhub. Endpoint protection + // (Kaspersky Endpoint Security in the field) kills or quarantines any + // launch codevhub initiates — the only mode it tolerates is the user + // typing the command in an elevated PowerShell themselves, which is + // field-proven to run to "Verification passed". Print that exact command, + // bare: the setup script itself defaults to profile-safe paths (shared + // %PUBLIC% dirs, console-user detection), so an elevation under a + // different admin account still installs to the real user's profile. if (platform === "windows") { - const wrapper = officeWrapperName(parsed.uninstall); - writeFileSync( - join(dir, wrapper), - officeWrapperContent(script, [ - ...scriptArgs, - ...officeWrapperBakedArgs(hostPlatform === "windows"), - ]), - ); const verb = parsed.uninstall ? "uninstaller" : "installer"; + const commandLine = officeManualWindowsCommand(script, scriptArgs); 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)`, + "(e.g. Kaspersky) is known to kill installers it launches. Run it " + + "yourself in an ELEVATED PowerShell:", ); - console.error(` 2. Double-click ${wrapper}`); + console.error(" 1. In this (or any) PowerShell window, run:"); + console.error(" Start-Process powershell -Verb RunAs"); console.error( - ` 3. Choose "Yes" when Windows asks for administrator permission`, + " (in the UAC dialog: More choices -> Use a different account -> enter the ADMIN username and password)", ); + console.error(" 2. Copy-paste these two lines into that window:"); + console.error(` cd "${dir}"`); + console.error(` ${commandLine}`); console.error( - ` (no admin rights? choose "No" - the install continues and each component asks for permission separately)`, + ' 3. Wait for the green "Verification passed" closing message', ); - console.error( - ` 4. 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", { + logInfo("office windows manual handoff", { action: parsed.uninstall ? "office.uninstall" : "office.install", - extra: { platform, dir, wrapper }, + extra: { platform, dir }, }); return 0; } @@ -522,7 +460,7 @@ export async function runSkillOffice( console.error( `\nRunning the ${parsed.uninstall ? "uninstaller" : "installer"} (${script})...\n`, ); - // Windows returned above with the right-click wrapper — only the bash + // Windows returned above with the printed manual command — only the bash // platforms reach the spawn. const command = "bash"; const args = [join(dir, script), ...scriptArgs]; diff --git a/tests/lib/download.test.ts b/tests/lib/download.test.ts index 0120b2d..5119f84 100644 --- a/tests/lib/download.test.ts +++ b/tests/lib/download.test.ts @@ -18,9 +18,7 @@ import { ensureStagingDir, installerArgs, migrateLegacyOfficeDir, - officeWrapperBakedArgs, - officeWrapperContent, - officeWrapperName, + officeManualWindowsCommand, runSkillOffice, uninstallerArgs, } from "@/lib/office.js"; @@ -399,10 +397,10 @@ 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. + test("windows staging stages files only and never spawns", async () => { + // Endpoint protection kills or quarantines anything codevhub launches - + // the windows flow must only download and print the manual + // elevated-PowerShell command. objects.set("/codev-office-windows.zip", BUNDLE); objects.set("/codev-office-windows-setup.ps1", SCRIPT); const dir = join(tempDir, "office"); @@ -424,48 +422,20 @@ describe("runSkillOffice", () => { ); 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"'); - // 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"); - // Profile-safe path baking: the shared modules dir always, but no - // -SkillsRoot on cross-platform staging - this host's homedir says - // nothing about the target machine's user. - expect(wrapper).toContain( - "-ModulesDir C:\\Users\\Public\\codev-office\\node_modules", - ); - expect(wrapper).not.toContain("-SkillsRoot"); - }); - - test("baked args pin the real user's skills root on a Windows host", () => { - expect(officeWrapperBakedArgs(false)).toEqual([ - "-ModulesDir", - "C:\\Users\\Public\\codev-office\\node_modules", - ]); - const onWindows = officeWrapperBakedArgs(true); - expect(onWindows.slice(0, 2)).toEqual([ - "-ModulesDir", - "C:\\Users\\Public\\codev-office\\node_modules", - ]); - expect(onWindows[2]).toBe("-SkillsRoot"); - expect(onWindows[3]).toContain(".config"); + expect(existsSync(join(dir, "codev-office-windows.zip"))).toBe(true); }); - test("wrapper quotes arguments containing spaces", () => { - const content = officeWrapperContent("s.ps1", [ + test("the manual command carries flags, quoting space-containing values", () => { + const line = officeManualWindowsCommand("codev-office-windows-setup.ps1", [ + "-SkipVerify", "-SkillsRoot", "C:\\Users\\Van Phong\\.config\\codev\\skills", ]); - expect(content).toContain( + expect(line).toContain( + "powershell -ExecutionPolicy Bypass -File .\\codev-office-windows-setup.ps1 -SkipVerify", + ); + // Space-containing paths are quoted so copy-paste survives them. + expect(line).toContain( '-SkillsRoot "C:\\Users\\Van Phong\\.config\\codev\\skills"', ); }); @@ -506,20 +476,6 @@ describe("runSkillOffice", () => { migrateLegacyOfficeDir(join(tempDir, "nope"), to); }); - 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 }); @@ -555,11 +511,7 @@ 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("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. + test("a Windows host never spawns the installer", async () => { objects.set("/codev-office-windows.zip", BUNDLE); objects.set("/codev-office-windows-setup.ps1", SCRIPT); const dir = join(tempDir, "office"); @@ -576,10 +528,6 @@ describe("runSkillOffice", () => { ); expect(code).toBe(0); 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 () => {