Skip to content

Commit edce853

Browse files
quickbeardQuickbeardclaude
authored
fix: print the manual elevated-PowerShell command instead of staging a .cmd (#246)
* Print the manual elevated-PowerShell command instead of staging a .cmd Field result: endpoint protection also quarantines the .ps1 when the self-elevating Install-CoDev-Office.cmd launches it. The only launch mode it tolerates - proven repeatedly - is the user typing the command in an elevated PowerShell themselves. The Windows flow now downloads everything and prints exactly that: open an elevated PowerShell (with the use-a-different-account UAC guidance), then copy-paste two lines - cd into the staging folder and the powershell -ExecutionPolicy Bypass -File command with the profile-safe -ModulesDir/-SkillsRoot paths baked in, quoted where they contain spaces. No wrapper file, no Explorer window, no spawn. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Standardize on Start-Process powershell -Verb RunAs for elevation Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Remove every .cmd/wrapper reference from the office flow officeWrapperBakedArgs -> officeBakedPathArgs, comments reworded to describe the launch constraint without the retired staged-launcher history, and the .cmd-absence test assertions dropped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Stop baking -ModulesDir - the setup script's default is now the shared path Companion to codev-scripts#23: %PUBLIC%\codev-office\node_modules is the script's own default, so the printed command carries only -SkillsRoot (the one per-machine value). Cross-platform staging now bakes nothing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Print the bare install command - the script now self-detects the user Companion to codev-scripts#23: the setup script resolves the signed-in user's skills root itself when elevated as a different account, and the modules dir default is the shared %PUBLIC% path. Nothing per-machine remains to bake, so the printed command is the same bare line the docs show - one command everywhere. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drop the stale baked-paths wording from the office comment and test Nothing is baked into the printed command any more - the setup script itself defaults to the shared %PUBLIC% dirs and detects the console user. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * bump version --------- Co-authored-by: Quickbeard <quickbeard@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 370ee36 commit edce853

3 files changed

Lines changed: 48 additions & 162 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codev-ai",
3-
"version": "0.5.11",
3+
"version": "0.5.12",
44
"description": "CoDev — AI Coding Agent Hub. Install, configure, and manage multiple AI coding agents.",
55
"keywords": [
66
"ai",

‎src/lib/office.ts‎

Lines changed: 32 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@ import {
66
readdirSync,
77
renameSync,
88
rmSync,
9-
writeFileSync,
109
} from "node:fs";
11-
import { homedir } from "node:os";
1210
import { join } from "node:path";
1311
import { OFFICE_DOWNLOADS_URL } from "@/lib/const.js";
1412
import { downloadFile } from "@/lib/download.js";
@@ -205,31 +203,17 @@ function manualRunCommand(platform: OfficePlatform, script: string): string {
205203
: `bash ${script}`;
206204
}
207205

208-
// Windows installs are handed to the user as a right-click .cmd instead of
209-
// being spawned from codevhub: endpoint protection (Kaspersky Endpoint
210-
// Security in the field) silently kills a powershell child of node.exe
211-
// mid-install, while the very same script from the very same folder finishes
212-
// when the user launches it themselves. The wrapper reproduces that
213-
// verified-good ancestry (explorer -> cmd -> powershell) and needs nothing
214-
// typed. Exported for tests.
215-
export function officeWrapperName(uninstall: boolean): string {
216-
return uninstall ? "Uninstall-CoDev-Office.cmd" : "Install-CoDev-Office.cmd";
217-
}
218-
219-
// Paths baked into the wrapper so a UAC elevation with a DIFFERENT admin
220-
// account cannot strand the install on the admin's profile: the JS module
221-
// tree goes to the shared, account-independent %PUBLIC% dir, and the skills
222-
// root is pinned to the REAL user's profile — codevhub runs unelevated as
223-
// that user, so homedir() is authoritative here. Cross-platform staging
224-
// (--platform windows from another OS) cannot know the target machine's
225-
// user, so only the modules dir is baked there. Exported for tests.
226-
export function officeWrapperBakedArgs(hostIsWindows: boolean): string[] {
227-
const publicDir = process.env.PUBLIC ?? "C:\\Users\\Public";
228-
const args = ["-ModulesDir", `${publicDir}\\codev-office\\node_modules`];
229-
if (hostIsWindows) {
230-
args.push("-SkillsRoot", `${homedir()}\\.config\\codev\\skills`);
231-
}
232-
return args;
206+
// The exact command the user types in an ELEVATED PowerShell — after field
207+
// testing every alternative launcher, this is the only launch mode the
208+
// endpoint protection tolerates: anything codevhub starts (directly or via a
209+
// staged launcher) gets killed or quarantined, while the same command typed
210+
// interactively runs to "Verification passed". Exported for tests.
211+
export function officeManualWindowsCommand(
212+
script: string,
213+
args: string[],
214+
): string {
215+
const argStr = args.map((a) => ` ${/\s/.test(a) ? `"${a}"` : a}`).join("");
216+
return `powershell -ExecutionPolicy Bypass -File .\\${script}${argStr}`;
233217
}
234218

235219
// 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 {
271255
}
272256
}
273257

274-
export function officeWrapperContent(script: string, args: string[]): string {
275-
// Self-elevating so a plain DOUBLE-CLICK is enough (some environments strip
276-
// "Run as administrator" from the context menu): when not elevated, the
277-
// .cmd relaunches itself elevated via a UAC prompt. If elevation is
278-
// declined or unavailable (non-admin account), it continues non-elevated —
279-
// the setup script supports that: each component installer raises its own
280-
// permission prompt, and a declined one only skips that component (fatal
281-
// for the .NET SDK alone).
282-
// %~dp0 = the .cmd's own folder (an elevated relaunch starts in System32);
283-
// `pause` keeps the window open so the closing "Verification passed" (or a
284-
// [FAIL] line) stays readable.
285-
const argStr = args.map((a) => ` ${/\s/.test(a) ? `"${a}"` : a}`).join("");
286-
return [
287-
"@echo off",
288-
'cd /d "%~dp0"',
289-
"net session >nul 2>&1",
290-
"if not errorlevel 1 goto :run",
291-
"echo Requesting administrator rights - choose Yes in the prompt...",
292-
"powershell -NoProfile -Command \"Start-Process -FilePath '%~f0' -Verb RunAs\" >nul 2>&1",
293-
"if not errorlevel 1 exit /b 0",
294-
"echo Continuing without administrator rights - each installer will ask for permission separately.",
295-
"echo.",
296-
":run",
297-
`powershell -ExecutionPolicy Bypass -File ".\\${script}"${argStr}`,
298-
"echo.",
299-
"pause",
300-
"",
301-
].join("\r\n");
302-
}
303-
304258
export function installerArgs(
305259
parsed: OfficeArgs,
306260
platform: OfficePlatform,
@@ -461,53 +415,37 @@ export async function runSkillOffice(
461415
? uninstallerArgs(parsed, platform)
462416
: installerArgs(parsed, platform);
463417

464-
// Windows: stage a right-click wrapper and stop — see officeWrapperName.
418+
// Windows: never launch the installer from codevhub. Endpoint protection
419+
// (Kaspersky Endpoint Security in the field) kills or quarantines any
420+
// launch codevhub initiates — the only mode it tolerates is the user
421+
// typing the command in an elevated PowerShell themselves, which is
422+
// field-proven to run to "Verification passed". Print that exact command,
423+
// bare: the setup script itself defaults to profile-safe paths (shared
424+
// %PUBLIC% dirs, console-user detection), so an elevation under a
425+
// different admin account still installs to the real user's profile.
465426
if (platform === "windows") {
466-
const wrapper = officeWrapperName(parsed.uninstall);
467-
writeFileSync(
468-
join(dir, wrapper),
469-
officeWrapperContent(script, [
470-
...scriptArgs,
471-
...officeWrapperBakedArgs(hostPlatform === "windows"),
472-
]),
473-
);
474427
const verb = parsed.uninstall ? "uninstaller" : "installer";
428+
const commandLine = officeManualWindowsCommand(script, scriptArgs);
475429
console.error(`\nFiles are in ${dir}.`);
476430
console.error(
477431
`codevhub does not auto-run the Windows ${verb}: endpoint protection ` +
478-
"(e.g. Kaspersky) is known to silently kill installers it launches. Instead:",
479-
);
480-
console.error(
481-
` 1. Open that folder in File Explorer (opened for you if possible)`,
432+
"(e.g. Kaspersky) is known to kill installers it launches. Run it " +
433+
"yourself in an ELEVATED PowerShell:",
482434
);
483-
console.error(` 2. Double-click ${wrapper}`);
435+
console.error(" 1. In this (or any) PowerShell window, run:");
436+
console.error(" Start-Process powershell -Verb RunAs");
484437
console.error(
485-
` 3. Choose "Yes" when Windows asks for administrator permission`,
438+
" (in the UAC dialog: More choices -> Use a different account -> enter the ADMIN username and password)",
486439
);
440+
console.error(" 2. Copy-paste these two lines into that window:");
441+
console.error(` cd "${dir}"`);
442+
console.error(` ${commandLine}`);
487443
console.error(
488-
` (no admin rights? choose "No" - the install continues and each component asks for permission separately)`,
444+
' 3. Wait for the green "Verification passed" closing message',
489445
);
490-
console.error(
491-
` 4. Wait for the closing message - the window stays open when done`,
492-
);
493-
if (hostPlatform === "windows") {
494-
try {
495-
const explorer = spawn("explorer.exe", [dir], {
496-
detached: true,
497-
stdio: "ignore",
498-
});
499-
// Best-effort convenience: spawn failures surface as an async
500-
// "error" event (which would crash the process if unhandled),
501-
// not as a throw — the printed path is enough either way.
502-
explorer.on("error", () => {});
503-
explorer.unref();
504-
} catch {
505-
// Same best-effort stance for synchronous spawn failures.
506-
}
507-
}
508-
logInfo("office windows handoff staged", {
446+
logInfo("office windows manual handoff", {
509447
action: parsed.uninstall ? "office.uninstall" : "office.install",
510-
extra: { platform, dir, wrapper },
448+
extra: { platform, dir },
511449
});
512450
return 0;
513451
}
@@ -522,7 +460,7 @@ export async function runSkillOffice(
522460
console.error(
523461
`\nRunning the ${parsed.uninstall ? "uninstaller" : "installer"} (${script})...\n`,
524462
);
525-
// Windows returned above with the right-click wrapper — only the bash
463+
// Windows returned above with the printed manual command — only the bash
526464
// platforms reach the spawn.
527465
const command = "bash";
528466
const args = [join(dir, script), ...scriptArgs];

‎tests/lib/download.test.ts‎

Lines changed: 15 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,7 @@ import {
1818
ensureStagingDir,
1919
installerArgs,
2020
migrateLegacyOfficeDir,
21-
officeWrapperBakedArgs,
22-
officeWrapperContent,
23-
officeWrapperName,
21+
officeManualWindowsCommand,
2422
runSkillOffice,
2523
uninstallerArgs,
2624
} from "@/lib/office.js";
@@ -399,10 +397,10 @@ describe("runSkillOffice", () => {
399397
expect(existsSync(join(dir, "codev-office-windows.zip"))).toBe(true);
400398
});
401399

402-
test("windows staging writes the right-click wrapper, flags baked in, never spawns", async () => {
403-
// Endpoint protection (KES) silently kills installers spawned by
404-
// codevhub on Windows, so the windows flow must never spawn - it
405-
// stages a .cmd the user runs via right-click -> Run as administrator.
400+
test("windows staging stages files only and never spawns", async () => {
401+
// Endpoint protection kills or quarantines anything codevhub launches -
402+
// the windows flow must only download and print the manual
403+
// elevated-PowerShell command.
406404
objects.set("/codev-office-windows.zip", BUNDLE);
407405
objects.set("/codev-office-windows-setup.ps1", SCRIPT);
408406
const dir = join(tempDir, "office");
@@ -424,48 +422,20 @@ describe("runSkillOffice", () => {
424422
);
425423
expect(code).toBe(0);
426424
expect(spawns).toEqual([]);
427-
const wrapper = readFileSync(join(dir, "Install-CoDev-Office.cmd"), "utf8");
428-
expect(wrapper).toContain(
429-
'powershell -ExecutionPolicy Bypass -File ".\\codev-office-windows-setup.ps1" -SkipVerify -ForceSkills',
430-
);
431-
expect(wrapper).toContain("pause");
432-
expect(wrapper).toContain('cd /d "%~dp0"');
433-
// Self-elevation: a plain double-click must request admin rights itself
434-
// (some environments strip "Run as administrator" from the context
435-
// menu) and fall back to a non-elevated run when declined.
436-
expect(wrapper).toContain("net session");
437-
expect(wrapper).toContain("Start-Process -FilePath '%~f0' -Verb RunAs");
438-
expect(wrapper).toContain(":run");
439-
expect(wrapper).toContain("Continuing without administrator rights");
440-
// Profile-safe path baking: the shared modules dir always, but no
441-
// -SkillsRoot on cross-platform staging - this host's homedir says
442-
// nothing about the target machine's user.
443-
expect(wrapper).toContain(
444-
"-ModulesDir C:\\Users\\Public\\codev-office\\node_modules",
445-
);
446-
expect(wrapper).not.toContain("-SkillsRoot");
447-
});
448-
449-
test("baked args pin the real user's skills root on a Windows host", () => {
450-
expect(officeWrapperBakedArgs(false)).toEqual([
451-
"-ModulesDir",
452-
"C:\\Users\\Public\\codev-office\\node_modules",
453-
]);
454-
const onWindows = officeWrapperBakedArgs(true);
455-
expect(onWindows.slice(0, 2)).toEqual([
456-
"-ModulesDir",
457-
"C:\\Users\\Public\\codev-office\\node_modules",
458-
]);
459-
expect(onWindows[2]).toBe("-SkillsRoot");
460-
expect(onWindows[3]).toContain(".config");
425+
expect(existsSync(join(dir, "codev-office-windows.zip"))).toBe(true);
461426
});
462427

463-
test("wrapper quotes arguments containing spaces", () => {
464-
const content = officeWrapperContent("s.ps1", [
428+
test("the manual command carries flags, quoting space-containing values", () => {
429+
const line = officeManualWindowsCommand("codev-office-windows-setup.ps1", [
430+
"-SkipVerify",
465431
"-SkillsRoot",
466432
"C:\\Users\\Van Phong\\.config\\codev\\skills",
467433
]);
468-
expect(content).toContain(
434+
expect(line).toContain(
435+
"powershell -ExecutionPolicy Bypass -File .\\codev-office-windows-setup.ps1 -SkipVerify",
436+
);
437+
// Space-containing paths are quoted so copy-paste survives them.
438+
expect(line).toContain(
469439
'-SkillsRoot "C:\\Users\\Van Phong\\.config\\codev\\skills"',
470440
);
471441
});
@@ -506,20 +476,6 @@ describe("runSkillOffice", () => {
506476
migrateLegacyOfficeDir(join(tempDir, "nope"), to);
507477
});
508478

509-
test("wrapper name and content cover the uninstall flow", () => {
510-
expect(officeWrapperName(false)).toBe("Install-CoDev-Office.cmd");
511-
expect(officeWrapperName(true)).toBe("Uninstall-CoDev-Office.cmd");
512-
const content = officeWrapperContent("codev-office-windows-uninstall.ps1", [
513-
"-Yes",
514-
"-SkillsOnly",
515-
]);
516-
expect(content).toContain(
517-
'powershell -ExecutionPolicy Bypass -File ".\\codev-office-windows-uninstall.ps1" -Yes -SkillsOnly',
518-
);
519-
// CRLF line endings - the file must open cleanly in cmd.exe.
520-
expect(content).toContain("\r\n");
521-
});
522-
523479
test("always refetches the setup script, but reuses a finished bundle", async () => {
524480
const dir = join(tempDir, "office");
525481
mkdirSync(dir, { recursive: true });
@@ -555,11 +511,7 @@ describe("runSkillOffice", () => {
555511
// The PowerShell branch is unreachable from a non-Windows host — a
556512
// cross-platform --platform forces download-only — so the host is stubbed to
557513
// keep the argv shape pinned on the Linux/macOS machines that run this suite.
558-
test("a Windows host stages the right-click wrapper instead of spawning", async () => {
559-
// Endpoint protection (KES in the field) silently kills a powershell
560-
// child of node.exe mid-install, so the installer must never be
561-
// spawned from codevhub on Windows — the user launches the staged
562-
// .cmd via right-click -> Run as administrator instead.
514+
test("a Windows host never spawns the installer", async () => {
563515
objects.set("/codev-office-windows.zip", BUNDLE);
564516
objects.set("/codev-office-windows-setup.ps1", SCRIPT);
565517
const dir = join(tempDir, "office");
@@ -576,10 +528,6 @@ describe("runSkillOffice", () => {
576528
);
577529
expect(code).toBe(0);
578530
expect(spawned).toBeNull();
579-
const wrapper = readFileSync(join(dir, "Install-CoDev-Office.cmd"), "utf8");
580-
expect(wrapper).toContain(
581-
'powershell -ExecutionPolicy Bypass -File ".\\codev-office-windows-setup.ps1" -SkipVerify',
582-
);
583531
});
584532

585533
test("exits 1 on an OS with no bundle and downloads nothing", async () => {

0 commit comments

Comments
 (0)