fix(scripts): harden Windows control UI i18n commands

This commit is contained in:
Vincent Koc
2026-05-24 14:46:55 +02:00
parent 5c15859759
commit 581c8a6375
3 changed files with 267 additions and 21 deletions

View File

@@ -126,6 +126,7 @@ Docs: https://docs.openclaw.ai
- Release/Windows: run installed `openclaw.cmd` verification through explicit `cmd.exe` wrapping so npm prepublish/postpublish checks avoid Node shell-argv warnings.
- Release/Windows: run release-check npm pack/install/root probes through the shared npm runner so native Windows avoids bare `npm` lookup and `.cmd` shell-argv handling.
- Release/Windows: run cross-OS release check `.cmd` shims through explicit `cmd.exe` wrapping so native Windows install and gateway probes avoid Node shell-argv handling.
- Control UI/Windows: run i18n Pi, npm, and pnpm helper commands through explicit Windows runners so native Windows translation sync avoids brittle `.cmd` launches.
- Plugins/Windows: run plugin npm package staging through the shared npm runner so native Windows release checks avoid bare `npm` lookup and `.cmd` shell-argv handling.
- Checks/Windows: route full `pnpm check` stage commands through the managed child runner so Windows avoids Node shell-argv deprecation warnings there too.
- Agents/fs: allow workspace-only host write/edit tools to write through in-workspace symlink directory parents while preserving outside-workspace symlink rejection. Fixes #84696. Thanks @garbagenetwork.

View File

@@ -8,6 +8,9 @@ import { createInterface } from "node:readline";
import { fileURLToPath, pathToFileURL } from "node:url";
import * as ts from "typescript";
import { formatErrorMessage } from "../src/infra/errors.ts";
import { resolveNpmRunner } from "./npm-runner.mjs";
import { resolvePnpmRunner } from "./pnpm-runner.mjs";
import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs";
interface TranslationMap {
[key: string]: string | TranslationMap;
@@ -920,6 +923,128 @@ type PiCommand = {
executable: string;
};
type ProcessCommand = {
args: string[];
env?: NodeJS.ProcessEnv;
executable: string;
shell: boolean;
windowsVerbatimArguments?: boolean;
};
type ResolveProcessCommandOptions = {
comSpec?: string;
env?: NodeJS.ProcessEnv;
execPath?: string;
existsSync?: (path: string) => boolean;
npmExecPath?: string;
platform?: NodeJS.Platform;
};
function portableExtension(value: string): string {
return path.posix.extname(value.split(/[/\\]/u).at(-1) ?? value).toLowerCase();
}
function isWindowsCommandShim(value: string, platform = process.platform): boolean {
const extension = portableExtension(value);
return platform === "win32" && (extension === ".cmd" || extension === ".bat");
}
function resolveEnvValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
return key === undefined ? undefined : env[key];
}
function commandFromRunner(runner: {
args: string[];
command: string;
env?: NodeJS.ProcessEnv;
shell: boolean;
windowsVerbatimArguments?: boolean;
}): ProcessCommand {
const command: ProcessCommand = {
args: runner.args,
executable: runner.command,
shell: runner.shell,
windowsVerbatimArguments: runner.windowsVerbatimArguments,
};
if (runner.env !== undefined) {
command.env = runner.env;
}
return command;
}
export function resolveControlUiI18nProcessCommand(
executable: string,
args: string[],
options: ResolveProcessCommandOptions = {},
): ProcessCommand {
const env = options.env ?? process.env;
const platform = options.platform ?? process.platform;
const comSpec = options.comSpec ?? resolveEnvValue(env, "ComSpec") ?? "cmd.exe";
if (isWindowsCommandShim(executable, platform)) {
return {
args: ["/d", "/s", "/c", buildCmdExeCommandLine(executable, args)],
executable: comSpec,
shell: false,
windowsVerbatimArguments: true,
};
}
return { args, executable, shell: false };
}
export function resolveControlUiI18nNpmInstallCommand(
packageSpec: string,
options: ResolveProcessCommandOptions = {},
): ProcessCommand {
return commandFromRunner(
resolveNpmRunner({
comSpec: options.comSpec,
env: options.env,
execPath: options.execPath,
existsSync: options.existsSync,
npmArgs: ["install", "--silent", "--no-audit", "--no-fund", packageSpec],
platform: options.platform,
}),
);
}
export function resolveControlUiI18nPnpmCommand(
args: string[],
options: ResolveProcessCommandOptions = {},
): ProcessCommand {
return commandFromRunner(
resolvePnpmRunner({
comSpec: options.comSpec,
npmExecPath: options.npmExecPath ?? process.env.npm_execpath,
nodeExecPath: options.execPath ?? process.execPath,
platform: options.platform,
pnpmArgs: args,
}),
);
}
export function resolvePiShimNodeCommand(
shimPath: string,
options: Pick<ResolveProcessCommandOptions, "existsSync" | "platform"> = {},
): PiCommand | null {
const platform = options.platform ?? process.platform;
if (!isWindowsCommandShim(shimPath, platform)) {
return null;
}
const cliPath = path.win32.join(
path.win32.dirname(shimPath),
"node_modules",
...PI_PACKAGE_NAME.split("/"),
"dist",
"cli.js",
);
const exists = options.existsSync ?? existsSync;
if (!exists(cliPath)) {
return null;
}
return { executable: "node", args: [cliPath] };
}
function resolvePiPackageVersion(): string {
return process.env[ENV_PI_PACKAGE_VERSION]?.trim() || DEFAULT_PI_PACKAGE_VERSION;
}
@@ -946,9 +1071,22 @@ export function resolveLocalPiCommand(root = ROOT): PiCommand | null {
async function resolvePiCommand(): Promise<PiCommand> {
const explicitExecutable = process.env[ENV_PI_EXECUTABLE]?.trim();
if (explicitExecutable) {
const explicitArgs = process.env[ENV_PI_ARGS]?.trim().split(/\s+/).filter(Boolean) ?? [];
const shimCommand = resolvePiShimNodeCommand(explicitExecutable);
if (shimCommand) {
return {
executable: shimCommand.executable,
args: [...shimCommand.args, ...explicitArgs],
};
}
if (isWindowsCommandShim(explicitExecutable)) {
throw new Error(
`${ENV_PI_EXECUTABLE} points to a Windows command shim that cannot safely carry the multiline i18n system prompt. Point it at node with ${ENV_PI_ARGS} set to the Pi package dist/cli.js path, or unset it so OpenClaw uses the managed Pi runtime.`,
);
}
return {
executable: explicitExecutable,
args: process.env[ENV_PI_ARGS]?.trim().split(/\s+/).filter(Boolean) ?? [],
args: explicitArgs,
};
}
@@ -956,6 +1094,13 @@ async function resolvePiCommand(): Promise<PiCommand> {
for (const entry of pathEntries) {
const candidate = path.join(entry, process.platform === "win32" ? "pi.cmd" : "pi");
if (existsSync(candidate)) {
const shimCommand = resolvePiShimNodeCommand(candidate);
if (shimCommand) {
return shimCommand;
}
if (process.platform === "win32") {
continue;
}
return { executable: candidate, args: [] };
}
}
@@ -975,15 +1120,8 @@ async function resolvePiCommand(): Promise<PiCommand> {
);
if (!existsSync(cliPath)) {
await mkdir(runtimeDir, { recursive: true });
await runProcess(
"npm",
[
"install",
"--silent",
"--no-audit",
"--no-fund",
`${PI_PACKAGE_NAME}@${resolvePiPackageVersion()}`,
],
await runProcessCommand(
resolveControlUiI18nNpmInstallCommand(`${PI_PACKAGE_NAME}@${resolvePiPackageVersion()}`),
{
cwd: runtimeDir,
rejectOnFailure: true,
@@ -999,16 +1137,17 @@ type RunProcessOptions = {
rejectOnFailure?: boolean;
};
async function runProcess(
executable: string,
args: string[],
async function runProcessCommand(
command: ProcessCommand,
options: RunProcessOptions = {},
): Promise<{ code: number; stderr: string; stdout: string }> {
return await new Promise((resolve, reject) => {
const child = spawn(executable, args, {
const child = spawn(command.executable, command.args, {
cwd: options.cwd ?? ROOT,
env: process.env,
env: command.env ?? process.env,
shell: command.shell,
stdio: ["pipe", "pipe", "pipe"],
windowsVerbatimArguments: command.windowsVerbatimArguments,
});
let stdout = "";
@@ -1028,7 +1167,11 @@ async function runProcess(
child.once("close", (code) => {
if ((code ?? 1) !== 0 && options.rejectOnFailure) {
reject(
new Error(`${executable} ${args.join(" ")} failed: ${stderr.trim() || stdout.trim()}`),
new Error(
`${command.executable} ${command.args.join(" ")} failed: ${
stderr.trim() || stdout.trim()
}`,
),
);
return;
}
@@ -1038,9 +1181,10 @@ async function runProcess(
}
async function formatGeneratedTypeScript(filePath: string, source: string): Promise<string> {
const result = await runProcess(
"pnpm",
["exec", "oxfmt", "--stdin-filepath", path.relative(ROOT, filePath)],
const result = await runProcessCommand(
resolveControlUiI18nPnpmCommand(
["exec", "oxfmt", "--stdin-filepath", path.relative(ROOT, filePath)],
),
{
input: source,
rejectOnFailure: true,
@@ -1167,10 +1311,13 @@ class PiRpcClient {
"--system-prompt",
systemPrompt,
];
const child = spawn(command.executable, args, {
const invocation = resolveControlUiI18nProcessCommand(command.executable, args);
const child = spawn(invocation.executable, invocation.args, {
cwd: ROOT,
env: process.env,
env: invocation.env ?? process.env,
shell: invocation.shell,
stdio: ["pipe", "pipe", "pipe"],
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});
const client = new PiRpcClient(child);

View File

@@ -0,0 +1,98 @@
import { win32 } from "node:path";
import { describe, expect, it } from "vitest";
import {
resolveControlUiI18nNpmInstallCommand,
resolveControlUiI18nPnpmCommand,
resolveControlUiI18nProcessCommand,
resolvePiShimNodeCommand,
} from "../../scripts/control-ui-i18n.ts";
describe("control-ui-i18n command resolution", () => {
const comSpec = String.raw`C:\Windows\System32\cmd.exe`;
it("resolves Windows pi.cmd shims to the node CLI before multiline RPC prompts", () => {
const piCmdPath = String.raw`C:\Users\runner\AppData\Roaming\npm\pi.cmd`;
const cliPath = win32.join(
win32.dirname(piCmdPath),
"node_modules",
"@earendil-works",
"pi-coding-agent",
"dist",
"cli.js",
);
const command = resolvePiShimNodeCommand(piCmdPath, {
existsSync: (candidate) => candidate === cliPath,
platform: "win32",
});
expect(command).toEqual({
args: [cliPath],
executable: "node",
});
if (!command) {
throw new Error("expected Windows Pi shim to resolve to a node command");
}
expect(
resolveControlUiI18nProcessCommand(
command.executable,
[...command.args, "--system-prompt", "line one\nline two"],
{
comSpec,
platform: "win32",
},
),
).toEqual({
args: [cliPath, "--system-prompt", "line one\nline two"],
executable: "node",
shell: false,
});
});
it("routes Windows Pi package installs through toolchain-local npm.cmd", () => {
const nodeExecPath = String.raw`C:\Program Files\nodejs\node.exe`;
const npmCmdPath = win32.resolve(win32.dirname(nodeExecPath), "npm.cmd");
expect(
resolveControlUiI18nNpmInstallCommand("@pi/pai@1.2.3", {
comSpec,
env: { ComSpec: comSpec },
execPath: nodeExecPath,
existsSync: (candidate) => candidate === npmCmdPath,
platform: "win32",
}),
).toEqual({
args: [
"/d",
"/s",
"/c",
String.raw`""C:\Program Files\nodejs\npm.cmd" install --silent --no-audit --no-fund @pi/pai@1.2.3"`,
],
executable: comSpec,
shell: false,
windowsVerbatimArguments: true,
});
});
it("routes Windows formatting through the active pnpm.cmd runner", () => {
expect(
resolveControlUiI18nPnpmCommand(
["exec", "oxfmt", "--stdin-filepath", "ui/src/i18n/generated.ts"],
{
comSpec,
npmExecPath: String.raw`C:\Program Files\nodejs\pnpm.cmd`,
platform: "win32",
},
),
).toEqual({
args: [
"/d",
"/s",
"/c",
String.raw`""C:\Program Files\nodejs\pnpm.cmd" exec oxfmt --stdin-filepath ui/src/i18n/generated.ts"`,
],
executable: comSpec,
shell: false,
windowsVerbatimArguments: true,
});
});
});