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
3 changes: 2 additions & 1 deletion src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { registerAiReviewCommand, handleAiReview } from "./commands/ai-review.js
import { registerQaCommand } from "./commands/qa.js";
import { registerReleaseCommand, handleRelease } from "./commands/release.js";
import { registerUpdateCommand, handleUpdate } from "./commands/update.js";
import { execCli } from "./utils/exec-cli.js";
import { registerDoctorCommand, handleDoctor } from "./commands/doctor.js";
import { registerModelCommand, handleModel } from "./commands/model.js";
import { registerFixPrCommand } from "./commands/fix-pr.js";
Expand Down Expand Up @@ -175,7 +176,7 @@ export function bootstrap(platform: Platform): void {
const currentVersion = getInstalledVersion(platform);
if (!currentVersion) return;

platform.exec("npm", ["view", "supipowers", "version"], { cwd: tmpdir() })
execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["view", "supipowers", "version"], { cwd: tmpdir() })
.then((result) => {
if (result.code !== 0) return;
const latest = result.stdout.trim();
Expand Down
5 changes: 3 additions & 2 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { formatReliabilitySection, loadReliabilitySummaries } from "../storage/r
import { getMetricsStore, getSessionId } from "../context-mode/hooks.js";
import { getProjectStatePath, getProjectStateDir } from "../workspace/state-paths.js";
import { basename } from "node:path";
import { execCli } from "../utils/exec-cli.js";

export interface CheckResult {
name: string;
Expand Down Expand Up @@ -435,14 +436,14 @@ export function checkMetrics(

export async function checkNpm(platform: Platform): Promise<CheckResult> {
try {
const vResult = await platform.exec("npm", ["--version"]);
const vResult = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["--version"]);
if (vResult.code !== 0) {
return { name: "npm", presence: { ok: false, detail: "npm not found" } };
}
const version = vResult.stdout.trim();
const presence = { ok: true, detail: `v${version}` };

const pingResult = await platform.exec("npm", ["ping"]);
const pingResult = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["ping"]);
if (pingResult.code === 0) {
return { name: "npm", presence, functional: { ok: true, detail: "Registry reachable" } };
}
Expand Down
3 changes: 2 additions & 1 deletion src/commands/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { loadModelConfig } from "../config/model-config.js";
import { getProjectStatePath } from "../workspace/state-paths.js";
import { cancelPlanTracking, startPlanTracking } from "../planning/approval-flow.js";
import { stopVisualServer } from "../visual/stop-server.js";
import { execCli } from "../utils/exec-cli.js";

modelRegistry.register({
id: "plan",
Expand Down Expand Up @@ -111,7 +112,7 @@ export function registerPlanCommand(platform: Platform): void {
const nodeModules = path.join(scriptsDir, "node_modules");
if (!fs.existsSync(nodeModules)) {
notifyInfo(ctx, "Installing visual companion dependencies...");
const installResult = await platform.exec("npm", ["install", "--production"], { cwd: scriptsDir });
const installResult = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["install", "--production"], { cwd: scriptsDir });
if (installResult.code !== 0) {
notifyError(ctx, "Failed to install visual companion dependencies", installResult.stderr);
debugLogger.log("visual_companion_dependency_install_failed", {
Expand Down
12 changes: 7 additions & 5 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
steerMempalaceInitialization,
} from "../mempalace/installer-helper.js";
import { loadConfig } from "../config/loader.js";
import { execCli, wrapExecForCli } from "../utils/exec-cli.js";

// ── Options builder ──────────────────────────────────────

Expand Down Expand Up @@ -59,7 +60,7 @@ async function updateSupipowers(
ctx.ui.notify(`Current version: v${currentVersion}`, "info");

// Check latest version on npm
const checkResult = await platform.exec("npm", ["view", "supipowers", "version"], { cwd: tmpdir() });
const checkResult = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["view", "supipowers", "version"], { cwd: tmpdir() });
if (checkResult.code !== 0) {
ctx.ui.notify("Failed to check for updates — npm view failed", "error");
return null;
Expand All @@ -78,7 +79,8 @@ async function updateSupipowers(
mkdirSync(tempDir, { recursive: true });

try {
const installResult = await platform.exec(
const installResult = await execCli(
(cmd, args, opts) => platform.exec(cmd, args, opts),
"npm", ["install", "--prefix", tempDir, `supipowers@${latestVersion}`],
{ cwd: tempDir },
);
Expand Down Expand Up @@ -130,10 +132,10 @@ async function updateSupipowers(
// Install runtime dependencies (handlebars, etc.)
// Without this, the extension fails to load because node_modules/ was deleted above.
ctx.ui.notify("Installing dependencies...", "info");
const bunInstall = await platform.exec("bun", ["install", "--production"], { cwd: extDir });
const bunInstall = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "bun", ["install", "--production"], { cwd: extDir });
if (bunInstall.code !== 0) {
// Fallback to npm if bun is not available (e.g. Windows without global bun)
const npmInstall = await platform.exec("npm", ["install", "--omit=dev"], { cwd: extDir });
const npmInstall = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["install", "--omit=dev"], { cwd: extDir });
if (npmInstall.code !== 0) {
ctx.ui.notify(
"Could not install extension dependencies.\n" +
Expand Down Expand Up @@ -177,7 +179,7 @@ async function updateSupipowers(

export function handleUpdate(platform: Platform, ctx: PlatformContext): void {
void (async () => {
const exec = (cmd: string, args: string[]) => platform.exec(cmd, args);
const exec = wrapExecForCli((cmd: string, args: string[]) => platform.exec(cmd, args));

// 1. Scan all dependencies
const allStatuses = await scanAll(exec);
Expand Down
7 changes: 4 additions & 3 deletions src/harness/anti_slop/fallow-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
type SlopBackendResult,
type SlopFinding,
} from "./backend.js";
import { execCli } from "../../utils/exec-cli.js";

const DEFAULT_TIMEOUT_MS = 60_000;

Expand Down Expand Up @@ -149,7 +150,7 @@ async function resolveInvocation(
}

try {
const probe = await platform.exec("npx", ["--no-install", "fallow", "--version"], { timeout: 5000 });
const probe = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npx", ["--no-install", "fallow", "--version"], { timeout: 5000 });
if (probe.code === 0) {
availabilityCache = { ok: true, via: "npx" };
return { ok: true, cmd: "npx", baseArgs: ["--no-install", "fallow"], via: "npx" };
Expand Down Expand Up @@ -187,7 +188,7 @@ async function runFallow(
const startedAt = Date.now();
let result;
try {
result = await platform.exec(invocation.cmd, args, {
result = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), invocation.cmd, args, {
cwd: opts.cwd,
timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
});
Expand Down Expand Up @@ -269,7 +270,7 @@ export class FallowAdapter implements SlopBackend {
if (opts.subtree) args.push("--path", opts.subtree);

try {
const result = await platform.exec(invocation.cmd, args, {
const result = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), invocation.cmd, args, {
cwd: opts.cwd,
timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
});
Expand Down
106 changes: 106 additions & 0 deletions src/utils/exec-cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { dirname, join } from "node:path";
import { existsSync } from "node:fs";

import type { ExecOptions, ExecResult } from "../platform/types.js";
import { findExecutable } from "./executable.js";

/**
* Cross-platform invocation for npm/npx that survives Windows `.cmd` shims.
*
* OMP's `platform.exec` is a thin wrapper over libuv's `uv_spawn`. On Windows
* that exposes two distinct bugs when the target is an npm-shipped CLI:
*
* 1. libuv does not consult `PATHEXT`, so spawning `"npm"` fails with
* `ENOENT: uv_spawn 'npm'` because the on-disk file is `npm.cmd`.
* 2. Even when callers resolve the absolute path, Node ≥18.20.2 hard-rejects
* spawning `.cmd`/`.bat` shims without `shell: true` (CVE-2024-27980).
* `ExecOptions` does not expose `shell`.
*
* Wrapping in `cmd.exe /d /s /c` is the canonical workaround, but only safe
* when the spawner sets `windowsVerbatimArguments: true` — Node's default
* CRT escaping double-quotes the command line and cmd's `/s` only strips one
* pair. We don't control the spawner, so we sidestep the whole problem by
* resolving the shim to the real `node <cli.js>` invocation, which is exactly
* what `npm.cmd` does internally. `node.exe` is a plain binary that libuv
* spawns without ceremony.
*
* Non-shim binaries (`bun`, `git`, `node`, `gh`, `rustup`, `go`, `pip`, …
* all ship as `.exe` on Windows) pass through untouched; POSIX always passes
* through.
*/

export type ExecFn = (
cmd: string,
args: string[],
opts?: ExecOptions,
) => Promise<ExecResult>;

interface ResolvedInvocation {
cmd: string;
prefixArgs: string[];
}

const NODE_SHIMS = new Set<string>(["npm", "npx"]);
const resolutionCache = new Map<string, ResolvedInvocation>();

function resolveNodeShim(command: string): ResolvedInvocation | null {
if (!NODE_SHIMS.has(command)) return null;

// node.exe is the executor; npm-cli.js / npx-cli.js sits next to it under
// node_modules/npm/bin/. We deliberately key off node's location (not the
// shim's) because `npm.cmd` can live in a user-global dir (e.g. nvm,
// %AppData%\npm) while the actual CLI bundle stays alongside node.
const nodeBin = findExecutable("node");
if (!nodeBin) return null;

const cliJs = join(
dirname(nodeBin),
"node_modules",
"npm",
"bin",
`${command}-cli.js`,
);
if (!existsSync(cliJs)) return null;

return { cmd: nodeBin, prefixArgs: [cliJs] };
}

function resolveInvocation(command: string): ResolvedInvocation {
if (process.platform !== "win32") {
return { cmd: command, prefixArgs: [] };
}
const cached = resolutionCache.get(command);
if (cached) return cached;

const resolved = resolveNodeShim(command) ?? { cmd: command, prefixArgs: [] };
resolutionCache.set(command, resolved);
return resolved;
}

/**
* Drop-in replacement for `platform.exec` callers that invoke npm/npx by name.
* Other commands pass through unchanged.
*/
export function execCli(
exec: ExecFn,
command: string,
args: string[],
opts?: ExecOptions,
): Promise<ExecResult> {
const resolved = resolveInvocation(command);
return exec(resolved.cmd, [...resolved.prefixArgs, ...args], opts);
}

/**
* Wrap an `ExecFn` so every call routes through `execCli`. Use when threading
* an exec callback into helpers that dispatch arbitrary tools by string name
* (e.g. the deps installer which splits install-command strings).
*/
export function wrapExecForCli(exec: ExecFn): ExecFn {
return (cmd, args, opts) => execCli(exec, cmd, args, opts);
}

/** Test-only: forget cached resolutions between unit-test runs. */
export function _resetExecCliCacheForTesting(): void {
resolutionCache.clear();
}
13 changes: 9 additions & 4 deletions tests/commands/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as path from "node:path";
import { resolveManagedVenvPaths } from "../../src/mempalace/runtime.js";
import { detectUvPlatform, uvTargetFor } from "../../src/mempalace/uv.js";
import { MEMPALACE_PACKAGE_VERSION } from "../../src/mempalace/upstream-limits.js";
import { normalizeExecCall } from "../helpers/exec-calls.js";
function makeDep(overrides: Partial<DependencyStatus> = {}): DependencyStatus {
return {
name: "test-tool",
Expand Down Expand Up @@ -104,7 +105,8 @@ describe("handleUpdate — dependency install", () => {
global: (...s: string[]) => path.join(tmpDir, "global", ...s),
agent: (...s: string[]) => path.join(tmpDir, "agent", ...s),
},
exec: mock(async (cmd: string, args: string[], opts?: any) => {
exec: mock(async (cmdRaw: string, argsRaw: string[], opts?: any) => {
const { cmd, args } = normalizeExecCall({ cmd: cmdRaw, args: argsRaw });
execCalls.push({ cmd, args, opts });

// npm view → return a newer version to trigger update
Expand Down Expand Up @@ -171,7 +173,8 @@ describe("handleUpdate — dependency install", () => {
global: (...s: string[]) => path.join(tmpDir, "global", ...s),
agent: (...s: string[]) => path.join(tmpDir, "agent", ...s),
},
exec: mock(async (cmd: string, args: string[], opts?: any) => {
exec: mock(async (cmdRaw: string, argsRaw: string[], opts?: any) => {
const { cmd, args } = normalizeExecCall({ cmd: cmdRaw, args: argsRaw });
execCalls.push({ cmd, args, opts });

if (cmd === "npm" && args[0] === "view") {
Expand Down Expand Up @@ -264,7 +267,8 @@ describe("handleUpdate — MemPalace prompt", () => {

test("does not run MemPalace setup when the user picks Skip", async () => {
const execCalls: Array<{ cmd: string; args: string[] }> = [];
const platform = platformWithExec(async (cmd, args) => {
const platform = platformWithExec(async (cmdRaw, argsRaw) => {
const { cmd, args } = normalizeExecCall({ cmd: cmdRaw, args: argsRaw });
execCalls.push({ cmd, args });
if (cmd === "npm" && args[0] === "view") return { stdout: "2.0.0\n", stderr: "", code: 0 };
if (cmd === "npm" && args.includes("--prefix")) {
Expand Down Expand Up @@ -317,7 +321,8 @@ describe("handleUpdate — MemPalace prompt", () => {
const venv = resolveManagedVenvPaths(venvRoot);

const execCalls: Array<{ cmd: string; args: string[] }> = [];
const platform = platformWithExec(async (cmd, args) => {
const platform = platformWithExec(async (cmdRaw, argsRaw) => {
const { cmd, args } = normalizeExecCall({ cmd: cmdRaw, args: argsRaw });
execCalls.push({ cmd, args });
if (cmd === "npm" && args[0] === "view") return { stdout: "2.0.0\n", stderr: "", code: 0 };
if (cmd === "npm" && args.includes("--prefix")) {
Expand Down
8 changes: 7 additions & 1 deletion tests/harness/anti_slop/fallow-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
FallowAdapter,
_resetFallowAvailabilityCacheForTests,
} from "../../../src/harness/anti_slop/fallow-adapter.js";
import { normalizeExecCall } from "../../helpers/exec-calls.js";

beforeEach(() => {
_resetFallowAvailabilityCacheForTests();
Expand All @@ -14,8 +15,13 @@ afterEach(() => {
});

function makePlatform(handler: (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string; code: number; killed?: boolean }>) {
// execCli rewrites npx → `node <path>/npx-cli.js` on Windows; normalize so
// adapter tests can keep matching on the logical command name.
return {
exec: mock(handler),
exec: mock(async (cmdRaw: string, argsRaw: string[]) => {
const { cmd, args } = normalizeExecCall({ cmd: cmdRaw, args: argsRaw });
return handler(cmd, args);
}),
paths: {} as any,
} as any;
}
Expand Down
48 changes: 48 additions & 0 deletions tests/helpers/exec-calls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Normalizes a captured `platform.exec` call back to its logical command name.
*
* `src/utils/exec-cli.ts` rewrites `npm` / `npx` invocations on Windows into
* `node <path-to-cli.js> <args...>` to work around two `uv_spawn` limitations
* (PATHEXT not consulted; Node ≥18.20.2 refuses `.cmd` shims without
* `shell: true`). Tests that assert against the call shape stay platform-
* stable by collapsing the rewritten form back to `{ cmd: "npm", args }`.
*
* Non-rewritten calls pass through untouched.
*/
export interface ExecCallShape {
cmd: string;
args: string[];
opts?: unknown;
}

const CLI_BY_FILENAME: Array<{ suffix: string; name: string }> = [
{ suffix: "npm-cli.js", name: "npm" },
{ suffix: "npx-cli.js", name: "npx" },
];

function looksLikeNode(cmd: string): boolean {
const lower = cmd.toLowerCase();
return (
lower === "node" ||
lower.endsWith("\\node.exe") ||
lower.endsWith("/node.exe") ||
lower.endsWith("\\node") ||
lower.endsWith("/node")
);
}

export function normalizeExecCall<T extends ExecCallShape>(call: T): T {
if (!looksLikeNode(call.cmd)) return call;
const first = call.args[0];
if (typeof first !== "string") return call;
for (const { suffix, name } of CLI_BY_FILENAME) {
if (first.endsWith(suffix)) {
return { ...call, cmd: name, args: call.args.slice(1) };
}
}
return call;
}

export function normalizeExecCalls<T extends ExecCallShape>(calls: T[]): T[] {
return calls.map((c) => normalizeExecCall(c));
}
Loading