Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveNodeExecutable, type NodeExecutableRuntime } from "./hook-command.js";
import { createNodeHookCommand, resolveNodeExecutable, type NodeExecutableRuntime } from "./hook-command.js";

const HOME = "/Users/test";

Expand Down Expand Up @@ -56,14 +56,36 @@ describe("resolveNodeExecutable", () => {
});
});

describe("createNodeHookCommand", () => {
it("single-quotes the command on POSIX platforms", () => {
expect(createNodeHookCommand("/Users/me/Library/Application Support/hook.mjs", runtime()))
.toBe("'node' '/Users/me/Library/Application Support/hook.mjs'");
});

it("double-quotes paths on Windows so cmd.exe and PowerShell can run them", () => {
const nodePath = "C:/Program Files/nodejs/node.exe";
expect(createNodeHookCommand("C:/Users/me/.codex/hooks/memmy-resume-hook.mjs", runtime({
platform: "win32",
env: { MEMMY_HOOK_NODE: nodePath },
executable: [nodePath]
}))).toBe("\"C:/Program Files/nodejs/node.exe\" \"C:/Users/me/.codex/hooks/memmy-resume-hook.mjs\"");
});

it("leaves a bare command name unquoted on Windows", () => {
expect(createNodeHookCommand("C:/Users/me/.codex/hooks/memmy-resume-hook.mjs", runtime({ platform: "win32" })))
.toBe("node \"C:/Users/me/.codex/hooks/memmy-resume-hook.mjs\"");
});
});

function runtime(overrides: {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
execPath?: string;
executable?: string[];
} = {}): NodeExecutableRuntime {
const executable = new Set(overrides.executable ?? []);
return {
platform: "darwin",
platform: overrides.platform ?? "darwin",
env: overrides.env ?? {},
execPath: overrides.execPath ?? "/missing/runtime/node",
hermesHomeDirectory: HOME,
Expand Down
15 changes: 12 additions & 3 deletions App/backend/src/adapters/outbound/skill-writer/hook-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ export interface NodeExecutableRuntime {
}

/** Creates a shell command that runs a hook script with Node, never Electron. */
export function createNodeHookCommand(hookScriptPath: string): string {
return `${shellQuote(resolveNodeExecutable())} ${shellQuote(hookScriptPath)}`;
export function createNodeHookCommand(
hookScriptPath: string,
runtime: NodeExecutableRuntime = defaultNodeExecutableRuntime()
): string {
return `${shellQuote(resolveNodeExecutable(runtime), runtime.platform)} ${shellQuote(hookScriptPath, runtime.platform)}`;
}

/** Resolves Node without ever selecting a packaged desktop application host. */
Expand Down Expand Up @@ -76,6 +79,12 @@ function isPackagedApplicationExecutable(value: string): boolean {
return name.includes("electron") || /\.app[\\/]contents[\\/]macos[\\/]/i.test(value);
}

function shellQuote(value: string): string {
function shellQuote(value: string, platform: NodeJS.Platform): string {
if (platform === "win32") {
// cmd.exe treats single quotes as literal characters and PowerShell parses
// them as string expressions, so the POSIX form never executes on Windows.
if (!/[\s"\\/]/.test(value)) return value;
return `"${value.replace(/"/g, '\\"')}"`;
}
return `'${value.replace(/'/g, "'\\''")}'`;
}