mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(scripts): harden Windows control UI i18n commands
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
98
test/scripts/control-ui-i18n.test.ts
Normal file
98
test/scripts/control-ui-i18n.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user