mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(scripts): harden Windows UI spawn behavior
This commit is contained in:
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- Scripts/UI/Windows: fix `pnpm ui:*` spawn `EINVAL` failures by restoring shell-backed launch for `.cmd`/`.bat` runners, narrowing shell usage to launcher types that require it, and rejecting unsafe forwarded shell metacharacters in UI script args. (#18594)
|
||||||
- Hooks/Session-memory: recover `/new` conversation summaries when session pointers are reset-path or missing `sessionFile`, and consistently prefer the newest `.jsonl.reset.*` transcript candidate for fallback extraction. (#18088)
|
- Hooks/Session-memory: recover `/new` conversation summaries when session pointers are reset-path or missing `sessionFile`, and consistently prefer the newest `.jsonl.reset.*` transcript candidate for fallback extraction. (#18088)
|
||||||
- Slack: restrict forwarded-attachment ingestion to explicit shared-message attachments and skip non-Slack forwarded `image_url` fetches, preventing non-forward attachment unfurls from polluting inbound agent context while preserving forwarded message handling.
|
- Slack: restrict forwarded-attachment ingestion to explicit shared-message attachments and skip non-Slack forwarded `image_url` fetches, preventing non-forward attachment unfurls from polluting inbound agent context while preserving forwarded message handling.
|
||||||
- Cron/Heartbeat: canonicalize session-scoped reminder `sessionKey` routing and preserve explicit flat `sessionKey` cron tool inputs, preventing enqueue/wake namespace drift for session-targeted reminders. (#18637) Thanks @vignesh07.
|
- Cron/Heartbeat: canonicalize session-scoped reminder `sessionKey` routing and preserve explicit flat `sessionKey` cron tool inputs, preventing enqueue/wake namespace drift for session-targeted reminders. (#18637) Thanks @vignesh07.
|
||||||
|
|||||||
139
scripts/ui.js
139
scripts/ui.js
@@ -9,6 +9,9 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const repoRoot = path.resolve(here, "..");
|
const repoRoot = path.resolve(here, "..");
|
||||||
const uiDir = path.join(repoRoot, "ui");
|
const uiDir = path.join(repoRoot, "ui");
|
||||||
|
|
||||||
|
const WINDOWS_SHELL_EXTENSIONS = new Set([".cmd", ".bat", ".com"]);
|
||||||
|
const WINDOWS_UNSAFE_SHELL_ARG_PATTERN = /[\r\n"&|<>^%!]/;
|
||||||
|
|
||||||
function usage() {
|
function usage() {
|
||||||
// keep this tiny; it's invoked from npm scripts too
|
// keep this tiny; it's invoked from npm scripts too
|
||||||
process.stderr.write("Usage: node scripts/ui.js <install|dev|build|test> [...args]\n");
|
process.stderr.write("Usage: node scripts/ui.js <install|dev|build|test> [...args]\n");
|
||||||
@@ -50,14 +53,52 @@ function resolveRunner() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function run(cmd, args) {
|
export function shouldUseShellForCommand(cmd, platform = process.platform) {
|
||||||
const isWindows = process.platform === "win32"; // Windows support
|
if (platform !== "win32") {
|
||||||
const child = spawn(cmd, args, {
|
return false;
|
||||||
|
}
|
||||||
|
const extension = path.extname(cmd).toLowerCase();
|
||||||
|
return WINDOWS_SHELL_EXTENSIONS.has(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertSafeWindowsShellArgs(args, platform = process.platform) {
|
||||||
|
if (platform !== "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const unsafeArg = args.find((arg) => WINDOWS_UNSAFE_SHELL_ARG_PATTERN.test(arg));
|
||||||
|
if (!unsafeArg) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SECURITY: `shell: true` routes through cmd.exe; reject risky metacharacters
|
||||||
|
// in forwarded args to prevent shell control-flow/env-expansion injection.
|
||||||
|
throw new Error(
|
||||||
|
`Unsafe Windows shell argument: ${unsafeArg}. Remove shell metacharacters (" & | < > ^ % !).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSpawnOptions(cmd, args, envOverride) {
|
||||||
|
const useShell = shouldUseShellForCommand(cmd);
|
||||||
|
if (useShell) {
|
||||||
|
assertSafeWindowsShellArgs(args);
|
||||||
|
}
|
||||||
|
return {
|
||||||
cwd: uiDir,
|
cwd: uiDir,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
env: process.env,
|
env: envOverride ?? process.env,
|
||||||
shell: isWindows,
|
...(useShell ? { shell: true } : {}),
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(cmd, args) {
|
||||||
|
let child;
|
||||||
|
try {
|
||||||
|
child = spawn(cmd, args, createSpawnOptions(cmd, args));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to launch ${cmd}:`, err);
|
||||||
|
process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
child.on("error", (err) => {
|
child.on("error", (err) => {
|
||||||
console.error(`Failed to launch ${cmd}:`, err);
|
console.error(`Failed to launch ${cmd}:`, err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -70,13 +111,14 @@ function run(cmd, args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runSync(cmd, args, envOverride) {
|
function runSync(cmd, args, envOverride) {
|
||||||
const isWindows = process.platform === "win32"; // Windows support
|
let result;
|
||||||
const result = spawnSync(cmd, args, {
|
try {
|
||||||
cwd: uiDir,
|
result = spawnSync(cmd, args, createSpawnOptions(cmd, args, envOverride));
|
||||||
stdio: "inherit",
|
} catch (err) {
|
||||||
env: envOverride ?? process.env,
|
console.error(`Failed to launch ${cmd}:`, err);
|
||||||
shell: isWindows,
|
process.exit(1);
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
if (result.signal) {
|
if (result.signal) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -101,42 +143,61 @@ function depsInstalled(kind) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [, , action, ...rest] = process.argv;
|
function resolveScriptAction(action) {
|
||||||
if (!action) {
|
if (action === "install") {
|
||||||
usage();
|
return null;
|
||||||
process.exit(2);
|
}
|
||||||
|
if (action === "dev") {
|
||||||
|
return "dev";
|
||||||
|
}
|
||||||
|
if (action === "build") {
|
||||||
|
return "build";
|
||||||
|
}
|
||||||
|
if (action === "test") {
|
||||||
|
return "test";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const runner = resolveRunner();
|
export function main(argv = process.argv.slice(2)) {
|
||||||
if (!runner) {
|
const [action, ...rest] = argv;
|
||||||
process.stderr.write("Missing UI runner: install pnpm, then retry.\n");
|
if (!action) {
|
||||||
process.exit(1);
|
usage();
|
||||||
}
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
const script =
|
const runner = resolveRunner();
|
||||||
action === "install"
|
if (!runner) {
|
||||||
? null
|
process.stderr.write("Missing UI runner: install pnpm, then retry.\n");
|
||||||
: action === "dev"
|
process.exit(1);
|
||||||
? "dev"
|
}
|
||||||
: action === "build"
|
|
||||||
? "build"
|
|
||||||
: action === "test"
|
|
||||||
? "test"
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (action !== "install" && !script) {
|
const script = resolveScriptAction(action);
|
||||||
usage();
|
if (action !== "install" && !script) {
|
||||||
process.exit(2);
|
usage();
|
||||||
}
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "install") {
|
||||||
|
run(runner.cmd, ["install", ...rest]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (action === "install") {
|
|
||||||
run(runner.cmd, ["install", ...rest]);
|
|
||||||
} else {
|
|
||||||
if (!depsInstalled(action === "test" ? "test" : "build")) {
|
if (!depsInstalled(action === "test" ? "test" : "build")) {
|
||||||
const installEnv =
|
const installEnv =
|
||||||
action === "build" ? { ...process.env, NODE_ENV: "production" } : process.env;
|
action === "build" ? { ...process.env, NODE_ENV: "production" } : process.env;
|
||||||
const installArgs = action === "build" ? ["install", "--prod"] : ["install"];
|
const installArgs = action === "build" ? ["install", "--prod"] : ["install"];
|
||||||
runSync(runner.cmd, installArgs, installEnv);
|
runSync(runner.cmd, installArgs, installEnv);
|
||||||
}
|
}
|
||||||
|
|
||||||
run(runner.cmd, ["run", script, ...rest]);
|
run(runner.cmd, ["run", script, ...rest]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDirectExecution = (() => {
|
||||||
|
const entry = process.argv[1];
|
||||||
|
return Boolean(entry && path.resolve(entry) === fileURLToPath(import.meta.url));
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (isDirectExecution) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|||||||
35
test/scripts/ui.test.ts
Normal file
35
test/scripts/ui.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { assertSafeWindowsShellArgs, shouldUseShellForCommand } from "../../scripts/ui.js";
|
||||||
|
|
||||||
|
describe("scripts/ui windows spawn behavior", () => {
|
||||||
|
it("enables shell for Windows command launchers that require cmd.exe", () => {
|
||||||
|
expect(
|
||||||
|
shouldUseShellForCommand("C:\\Users\\dev\\AppData\\Local\\pnpm\\pnpm.CMD", "win32"),
|
||||||
|
).toBe(true);
|
||||||
|
expect(shouldUseShellForCommand("C:\\tools\\pnpm.bat", "win32")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not enable shell for non-shell launchers", () => {
|
||||||
|
expect(shouldUseShellForCommand("C:\\Program Files\\nodejs\\node.exe", "win32")).toBe(false);
|
||||||
|
expect(shouldUseShellForCommand("/usr/local/bin/pnpm", "linux")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows safe forwarded args when shell mode is required on Windows", () => {
|
||||||
|
expect(() =>
|
||||||
|
assertSafeWindowsShellArgs(["run", "build", "--filter", "@openclaw/ui"], "win32"),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects dangerous forwarded args when shell mode is required on Windows", () => {
|
||||||
|
expect(() => assertSafeWindowsShellArgs(["run", "build", "evil&calc"], "win32")).toThrow(
|
||||||
|
/unsafe windows shell argument/i,
|
||||||
|
);
|
||||||
|
expect(() => assertSafeWindowsShellArgs(["run", "build", "%PATH%"], "win32")).toThrow(
|
||||||
|
/unsafe windows shell argument/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reject args on non-windows platforms", () => {
|
||||||
|
expect(() => assertSafeWindowsShellArgs(["contains&metacharacters"], "linux")).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user