Skip to content
Closed
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
63 changes: 54 additions & 9 deletions src/tray/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,16 @@ function quoteRunValue(value: string): string {
return `\"${value}\"`;
}

/** Command persisted under HKCU Run. Every value is an owned absolute package/home path. */
export function buildWindowsTrayRunCommand(entry: WindowsTrayEntry, powershell = windowsPowerShellPath()): string {
function installedTrayLauncherPath(): string {
return join(getConfigDir(), "opencodex-tray.vbs");
}

function quoteVbsPath(value: string): string {
return value.replace(/"/g, '""');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Full PowerShell invocation used by the owned VBS launcher (not written to HKCU Run). */
export function buildWindowsTrayPowerShellCommand(entry: WindowsTrayEntry, powershell = windowsPowerShellPath()): string {
return [
quoteRunValue(powershell),
"-NoLogo",
Expand All @@ -169,6 +177,27 @@ export function buildWindowsTrayRunCommand(entry: WindowsTrayEntry, powershell =
].join(" ");
}

/** Short HKCU Run command (must stay ≤260 chars under long Windows user/npm paths). */
export function buildWindowsTrayRunCommand(entry: WindowsTrayEntry & { launcherPath: string }): string {
const wscript = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "wscript.exe");
return `${quoteRunValue(wscript)} //B //NoLogo ${quoteRunValue(entry.launcherPath)}`;
}

export function buildWindowsTrayLauncherScript(entry: WindowsTrayEntry, powershell = windowsPowerShellPath()): string {
const command = buildWindowsTrayPowerShellCommand(entry, powershell);
// VBS CreateObject("WScript.Shell").Run command, 0, False — hidden, non-blocking.
return [
"' OpenCodex owned tray launcher — do not edit by hand.",
`CreateObject("WScript.Shell").Run "${quoteVbsPath(command)}", 0, False`,
Comment thread
Wibias marked this conversation as resolved.
"",
].join("\r\n");
}

/** @deprecated Prefer buildWindowsTrayPowerShellCommand; kept for callers that still expect the long form. */
export function buildWindowsTrayLegacyRunCommand(entry: WindowsTrayEntry, powershell = windowsPowerShellPath()): string {
return buildWindowsTrayPowerShellCommand(entry, powershell);
}

function readState(): WindowsTrayState | null {
try {
const state = JSON.parse(readFileSync(trayStatePath(), "utf8")) as Partial<WindowsTrayState>;
Expand Down Expand Up @@ -204,7 +233,11 @@ function replaceOwnedFile(path: string, contents: string | Buffer): void {
}
}

function writeState(entry: WindowsTrayEntry, runValue: string, runCommand: string): void {
function writeState(
entry: WindowsTrayEntry & { launcherPath: string },
runValue: string,
runCommand: string,
): void {
const path = trayStatePath();
replaceOwnedFile(path, JSON.stringify({ version: TRAY_STATE_VERSION, ...entry, runValue, runCommand }, null, 2) + "\n");
}
Expand Down Expand Up @@ -376,7 +409,8 @@ function trayStatusFrom(registered: string | null): WindowsTrayStatus {
const running = heartbeatProcessAlive(heartbeat);
const registrationOwned = state !== null
&& registered === state.runCommand
&& [state.bun, state.cli, state.script, ...installedTrayIconPaths()].every(path => existsSync(path));
&& [state.bun, state.cli, state.script, ...(state.launcherPath ? [state.launcherPath] : []), ...installedTrayIconPaths()]
.every(path => existsSync(path));
const stale = windowsTrayRegistrationIsStale({
registered: registered !== null,
registrationOwned,
Expand Down Expand Up @@ -507,7 +541,12 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {
}
recordOwnedConfigPath(getConfigDir(), trayStatePath());
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
const runCommand = buildWindowsTrayRunCommand(entry);
const launcherPath = installedTrayLauncherPath();
Comment thread
Wibias marked this conversation as resolved.
const entryWithLauncher = { ...entry, launcherPath };
const runCommand = buildWindowsTrayRunCommand(entryWithLauncher);
if (runCommand.length > 260) {
throw new Error(`Tray Run command exceeds the Windows 260-character limit (${runCommand.length} chars).`);
}
const runValue = windowsTrayRunValue(entry.opencodexHome);
const existing = readOwnedRunValue(runValue);
const state = readState();
Expand All @@ -517,6 +556,9 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {
if (existsSync(entry.script) && (!state || resolve(state.script) !== resolve(entry.script))) {
throw new Error(`Refusing to overwrite an unowned tray script at ${entry.script}.`);
}
if (existsSync(launcherPath) && (!state?.launcherPath || resolve(state.launcherPath) !== resolve(launcherPath))) {
throw new Error(`Refusing to overwrite an unowned tray launcher at ${launcherPath}.`);
}
if (!state && iconPairs.some(pair => existsSync(pair.installed))) {
throw new Error("Refusing to overwrite unowned Windows tray icon assets.");
}
Expand All @@ -531,6 +573,7 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {

const previousStateBytes = existsSync(trayStatePath()) ? readFileSync(trayStatePath()) : null;
const previousScriptBytes = existsSync(entry.script) ? readFileSync(entry.script) : null;
const previousLauncherBytes = existsSync(launcherPath) ? readFileSync(launcherPath) : null;
const previousIconBytes = new Map(iconPairs.map(pair => [
pair.installed,
existsSync(pair.installed) ? readFileSync(pair.installed) : null,
Expand All @@ -540,6 +583,10 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {
if (previousScriptBytes) replaceOwnedFile(entry.script, previousScriptBytes);
else if (existsSync(entry.script)) unlinkSync(entry.script);
} catch { /* rollback best-effort */ }
try {
if (previousLauncherBytes) replaceOwnedFile(launcherPath, previousLauncherBytes);
else if (existsSync(launcherPath)) unlinkSync(launcherPath);
} catch { /* rollback best-effort */ }
for (const [path, contents] of previousIconBytes) {
try {
if (contents) replaceOwnedFile(path, contents);
Expand Down Expand Up @@ -567,8 +614,9 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {
if (!hardenedDir.ok) throw new Error("Windows tray directory ACL hardening did not complete; refusing to install persistence.");
replaceOwnedFile(entry.script, readFileSync(sourceScript));
for (const pair of iconPairs) replaceOwnedFile(pair.installed, readFileSync(pair.source));
replaceOwnedFile(launcherPath, Buffer.from("\uFEFF" + buildWindowsTrayLauncherScript(entry), "utf16le"));
runRegistry(["add", RUN_KEY, "/v", runValue, "/t", "REG_SZ", "/d", runCommand, "/f", "/reg:64"]);
writeState(entry, runValue, runCommand);
writeState(entryWithLauncher, runValue, runCommand);
} catch (error) {
restorePreviousInstall();
throw error;
Expand All @@ -578,9 +626,6 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {
restorePreviousInstall();
throw new Error("The tray startup registration was installed, but the tray process did not become healthy.");
}
if (state?.launcherPath && existsSync(state.launcherPath)) {
try { unlinkSync(state.launcherPath); } catch { /* old owned VBS is inert after a committed Run replacement */ }
}
return getWindowsTrayStatus();
}

Expand Down
25 changes: 25 additions & 0 deletions tests/windows-tray-run-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, test } from "bun:test";
import {
buildWindowsTrayRunCommand,
buildWindowsTrayPowerShellCommand,
} from "../src/tray/windows";

describe("windows tray Run registration (#696)", () => {
test("short wscript Run command stays within the 260-character Windows limit under long paths", () => {
const longHome = "C:\\Users\\VeryLongWindowsUserNameForTestingPaths\\AppData\\Roaming\\npm\\node_modules\\@bitkyc08\\opencodex";
const entry = {
bun: `${longHome}\\node_modules\\bun\\bin\\bun.exe`,
cli: `${longHome}\\src\\cli\\index.ts`,
script: "C:\\Users\\VeryLongWindowsUserNameForTestingPaths\\.opencodex\\opencodex-tray.ps1",
codexHome: "C:\\Users\\VeryLongWindowsUserNameForTestingPaths\\.codex",
opencodexHome: "C:\\Users\\VeryLongWindowsUserNameForTestingPaths\\.opencodex",
launcherPath: "C:\\Users\\VeryLongWindowsUserNameForTestingPaths\\.opencodex\\opencodex-tray.vbs",
};
const runCommand = buildWindowsTrayRunCommand(entry);
expect(runCommand.length).toBeLessThanOrEqual(260);
expect(runCommand.toLowerCase()).toContain("wscript.exe");
expect(runCommand).toContain("opencodex-tray.vbs");
// The long PowerShell form still exists for the VBS body, but must not be the Run value.
expect(buildWindowsTrayPowerShellCommand(entry).length).toBeGreaterThan(260);
});
});
32 changes: 31 additions & 1 deletion tests/windows-tray.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from "nod
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
buildWindowsTrayLauncherScript,
buildWindowsTrayPowerShellCommand,
buildWindowsTrayRunCommand,
launchWindowsTrayHost,
parseWindowsTrayRunValue,
Expand Down Expand Up @@ -40,11 +42,39 @@ describe("Windows tray packaging and command safety", () => {
});

test("quotes metacharacter and Unicode paths without shell interpolation", () => {
const powershellCommand = buildWindowsTrayRunCommand(entry, "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
const powershellCommand = buildWindowsTrayPowerShellCommand(entry, "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
expect(powershellCommand).toContain(`-File "${entry.script}"`);
expect(powershellCommand).toContain(`-OpenCodexHome "${entry.opencodexHome}"`);
expect(powershellCommand).not.toContain("cmd /c");
expect(powershellCommand).not.toContain("-Command");
const runCommand = buildWindowsTrayRunCommand({
...entry,
launcherPath: `${entry.opencodexHome}\\opencodex-tray.vbs`,
});
expect(runCommand.toLowerCase()).toContain("wscript.exe");
expect(runCommand.length).toBeLessThanOrEqual(260);
});
test("keeps UNC backslashes literal in the VBS Run command", () => {
const uncRoot = "\\\\server\\share";
const uncEntry: WindowsTrayEntry = {
bun: `${uncRoot}\\tools\\bun.exe`,
cli: `${uncRoot}\\repo\\src\\cli\\index.ts`,
script: `${uncRoot}\\repo\\src\\tray\\windows-tray.ps1`,
codexHome: "C:\\Users\\Test\\.codex",
opencodexHome: `${uncRoot}\\opencodex`,
};
const launcher = buildWindowsTrayLauncherScript(uncEntry);
expect(launcher).toContain(`${uncRoot}\\tools\\bun.exe`);
expect(launcher).not.toMatch(/\\\\\\\\server/);
});


test("preserves non-ASCII paths in the tray launcher script and UTF-16LE install encoding", () => {
const launcher = buildWindowsTrayLauncherScript(entry);
expect(launcher).toContain("사용자 공간");
const encoded = Buffer.from("\uFEFF" + launcher, "utf16le");
expect(encoded.subarray(0, 2).equals(Buffer.from([0xff, 0xfe]))).toBe(true);
expect(encoded.toString("utf16le")).toContain("사용자 공간");
Comment thread
Wibias marked this conversation as resolved.
});

test("rejects quote and control-character path injection", () => {
Expand Down
Loading