Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
126 changes: 32 additions & 94 deletions src/lib/office.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand All @@ -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];
Expand Down
82 changes: 15 additions & 67 deletions tests/lib/download.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,7 @@
ensureStagingDir,
installerArgs,
migrateLegacyOfficeDir,
officeWrapperBakedArgs,
officeWrapperContent,
officeWrapperName,
officeManualWindowsCommand,
runSkillOffice,
uninstallerArgs,
} from "@/lib/office.js";
Expand Down Expand Up @@ -350,7 +348,7 @@
return 0;
},
);
expect(code).toBe(0);

Check failure on line 351 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > --download-only stages both files and never spawns

AssertionError: expected 1 to be +0 // Object.is equality - Expected + Received - 0 + 1 ❯ tests/lib/download.test.ts:351:16
expect(spawns).toEqual([]);
expect(readFileSync(join(dir, bundleName)).equals(BUNDLE)).toBe(true);
expect(readFileSync(join(dir, scriptName)).equals(SCRIPT)).toBe(true);
Expand All @@ -367,7 +365,7 @@
return 0;
},
);
expect(code).toBe(0);

Check failure on line 368 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > runs the installer via bash with translated flags

AssertionError: expected 1 to be +0 // Object.is equality - Expected + Received - 0 + 1 ❯ tests/lib/download.test.ts:368:16
expect(spawned).toEqual({
command: "bash",
args: [join(dir, scriptName), "--skip-verify"],
Expand All @@ -378,7 +376,7 @@
test("propagates the installer's exit code", async () => {
const dir = join(tempDir, "office");
const code = await runSkillOffice(["--dir", dir], baseUrl, async () => 7);
expect(code).toBe(7);

Check failure on line 379 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > propagates the installer's exit code

AssertionError: expected 1 to be 7 // Object.is equality - Expected + Received - 7 + 1 ❯ tests/lib/download.test.ts:379:16
});

test("a cross-platform --platform forces download-only", async () => {
Expand All @@ -399,10 +397,10 @@
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");
Expand All @@ -424,48 +422,20 @@
);
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"',
);
});
Expand All @@ -478,7 +448,7 @@
const fallback = join(tempDir, "fallback-office");
try {
const dir = ensureStagingDir(preferred, fallback);
expect(dir).toBe(fallback);

Check failure on line 451 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > ensureStagingDir falls back when the preferred dir is unwritable

AssertionError: expected 'C:\Users\RUNNER~1\AppData\Local\Temp\…' to be 'C:\Users\RUNNER~1\AppData\Local\Temp\…' // Object.is equality Expected: "C:\Users\RUNNER~1\AppData\Local\Temp\codev-download-WISrCK\fallback-office" Received: "C:\Users\RUNNER~1\AppData\Local\Temp\codev-download-WISrCK\locked\codev-office" ❯ tests/lib/download.test.ts:451:16
expect(existsSync(fallback)).toBe(true);
} finally {
chmodSync(locked, 0o755);
Expand Down Expand Up @@ -506,20 +476,6 @@
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 });
Expand All @@ -529,7 +485,7 @@
["--download-only", "--dir", dir],
baseUrl,
);
expect(code).toBe(0);

Check failure on line 488 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > always refetches the setup script, but reuses a finished bundle

AssertionError: expected 1 to be +0 // Object.is equality - Expected + Received - 0 + 1 ❯ tests/lib/download.test.ts:488:16
expect(readFileSync(join(dir, scriptName)).equals(SCRIPT)).toBe(true);
// No checksum to disagree with, so the existing bundle is trusted as-is.
expect(readFileSync(join(dir, bundleName), "utf8")).toBe("stale-bundle");
Expand All @@ -546,7 +502,7 @@
["--download-only", "--dir", dir],
baseUrl,
);
expect(code).toBe(0);

Check failure on line 505 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > drops a stale .partial for the script instead of resuming onto it

AssertionError: expected 1 to be +0 // Object.is equality - Expected + Received - 0 + 1 ❯ tests/lib/download.test.ts:505:16
expect(readFileSync(join(dir, scriptName)).equals(SCRIPT)).toBe(true);
// Both requests went out without a Range header.
expect(rangeLog).toEqual([undefined, undefined]);
Expand All @@ -555,11 +511,7 @@
// 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");
Expand All @@ -576,10 +528,6 @@
);
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 () => {
Expand Down Expand Up @@ -617,7 +565,7 @@
return 0;
},
);
expect(code).toBe(0);

Check failure on line 568 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > --uninstall fetches only the uninstall script and runs it with passthroughs

AssertionError: expected 1 to be +0 // Object.is equality - Expected + Received - 0 + 1 ❯ tests/lib/download.test.ts:568:16
expect(spawned).toEqual({
command: "bash",
args: [join(dir, uninstallName), "--yes", "--skills-only"],
Expand Down
Loading