fix: harden windows native updates

This commit is contained in:
Peter Steinberger
2026-03-12 23:41:28 +00:00
parent 35aafd7ca8
commit 91b701e183
7 changed files with 290 additions and 52 deletions

View File

@@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
- Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated `session.store` roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.
- Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process `HOME`/`OPENCLAW_HOME` changes no longer reuse stale plugin state or misreport `~/...` plugins as untracked. (#44046) thanks @gumadeiras.
- Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and `models list --plain`, and migrate legacy duplicated `openrouter/openrouter/...` config entries forward on write.
- Windows/native update: make package installs use the npm update path instead of the git path, carry portable Git into native Windows updates, and mirror the installer's Windows npm env so `openclaw update` no longer dies early on missing `git` or `node-llama-cpp` download setup.
- Sandbox/write: preserve pinned mutation-helper payload stdin so sandboxed `write` no longer reports success while creating empty files. (#43876) Thanks @glitch418x.
- Security/exec approvals: escape invisible Unicode format characters in approval prompts so zero-width command text renders as visible `\u{...}` escapes instead of spoofing the reviewed command. (`GHSA-pcqg-f7rg-xfvv`)(#43687) Thanks @EkiXu and @vincentkoc.
- Security/exec detection: normalize compatibility Unicode and strip invisible formatting code points before obfuscation checks so zero-width and fullwidth command tricks no longer suppress heuristic detection. (`GHSA-9r3v-37xh-2cf6`)(#44091) Thanks @wooluo and @vincentkoc.

View File

@@ -1,3 +1,4 @@
import fs from "node:fs/promises";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.openclaw.js";
@@ -390,14 +391,13 @@ describe("update-cli", () => {
},
{
name: "defaults to stable channel for package installs when unset",
mode: "npm" as const,
options: { yes: true },
prepare: async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
},
expectedChannel: "stable" as const,
expectedTag: "latest",
expectedChannel: undefined as "stable" | undefined,
expectedTag: undefined as string | undefined,
},
{
name: "uses stored beta channel when configured",
@@ -414,14 +414,25 @@ describe("update-cli", () => {
},
])("$name", async ({ mode, options, prepare, expectedChannel, expectedTag }) => {
await prepare();
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult({ mode }));
if (mode) {
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult({ mode }));
}
await updateCommand(options);
const call = expectUpdateCallChannel(expectedChannel);
if (expectedTag !== undefined) {
expect(call?.tag).toBe(expectedTag);
if (expectedChannel !== undefined) {
const call = expectUpdateCallChannel(expectedChannel);
if (expectedTag !== undefined) {
expect(call?.tag).toBe(expectedTag);
}
return;
}
expect(runGatewayUpdate).not.toHaveBeenCalled();
expect(runCommandWithTimeout).toHaveBeenCalledWith(
["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"],
expect.any(Object),
);
});
it("falls back to latest when beta tag is older than release", async () => {
@@ -436,32 +447,78 @@ describe("update-cli", () => {
tag: "latest",
version: "1.2.3-1",
});
vi.mocked(runGatewayUpdate).mockResolvedValue(
makeOkUpdateResult({
mode: "npm",
}),
);
await updateCommand({});
const call = expectUpdateCallChannel("beta");
expect(call?.tag).toBe("latest");
expect(runGatewayUpdate).not.toHaveBeenCalled();
expect(runCommandWithTimeout).toHaveBeenCalledWith(
["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"],
expect.any(Object),
);
});
it("honors --tag override", async () => {
const tempDir = createCaseDir("openclaw-update");
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir);
vi.mocked(runGatewayUpdate).mockResolvedValue(
makeOkUpdateResult({
mode: "npm",
}),
);
mockPackageInstallStatus(tempDir);
await updateCommand({ tag: "next" });
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
expect(call?.tag).toBe("next");
expect(runGatewayUpdate).not.toHaveBeenCalled();
expect(runCommandWithTimeout).toHaveBeenCalledWith(
["npm", "i", "-g", "openclaw@next", "--no-fund", "--no-audit", "--loglevel=error"],
expect.any(Object),
);
});
it("prepends portable Git PATH for package updates on Windows", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const tempDir = createCaseDir("openclaw-update");
const localAppData = createCaseDir("openclaw-localappdata");
const portableGitMingw = path.join(
localAppData,
"OpenClaw",
"deps",
"portable-git",
"mingw64",
"bin",
);
const portableGitUsr = path.join(
localAppData,
"OpenClaw",
"deps",
"portable-git",
"usr",
"bin",
);
await fs.mkdir(portableGitMingw, { recursive: true });
await fs.mkdir(portableGitUsr, { recursive: true });
mockPackageInstallStatus(tempDir);
pathExists.mockImplementation(
async (candidate: string) => candidate === portableGitMingw || candidate === portableGitUsr,
);
await withEnvAsync({ LOCALAPPDATA: localAppData }, async () => {
await updateCommand({ yes: true });
});
platformSpy.mockRestore();
const updateCall = vi
.mocked(runCommandWithTimeout)
.mock.calls.find(
(call) =>
Array.isArray(call[0]) &&
call[0][0] === "npm" &&
call[0][1] === "i" &&
call[0][2] === "-g",
);
const mergedPath = updateCall?.[1]?.env?.Path ?? updateCall?.[1]?.env?.PATH ?? "";
expect(mergedPath.split(path.delimiter).slice(0, 2)).toEqual([
portableGitMingw,
portableGitUsr,
]);
expect(updateCall?.[1]?.env?.NPM_CONFIG_SCRIPT_SHELL).toBe("cmd.exe");
expect(updateCall?.[1]?.env?.NODE_LLAMA_CPP_SKIP_DOWNLOAD).toBe("1");
});
it("updateCommand outputs JSON when --json is set", async () => {
@@ -648,15 +705,15 @@ describe("update-cli", () => {
name: "requires confirmation without --yes",
options: {},
shouldExit: true,
shouldRunUpdate: false,
shouldRunPackageUpdate: false,
},
{
name: "allows downgrade with --yes",
options: { yes: true },
shouldExit: false,
shouldRunUpdate: true,
shouldRunPackageUpdate: true,
},
])("$name in non-interactive mode", async ({ options, shouldExit, shouldRunUpdate }) => {
])("$name in non-interactive mode", async ({ options, shouldExit, shouldRunPackageUpdate }) => {
await setupNonInteractiveDowngrade();
await updateCommand(options);
@@ -667,7 +724,12 @@ describe("update-cli", () => {
expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe(
shouldExit,
);
expect(vi.mocked(runGatewayUpdate).mock.calls.length > 0).toBe(shouldRunUpdate);
expect(vi.mocked(runGatewayUpdate).mock.calls.length > 0).toBe(false);
expect(
vi
.mocked(runCommandWithTimeout)
.mock.calls.some((call) => Array.isArray(call[0]) && call[0][0] === "npm"),
).toBe(shouldRunPackageUpdate);
});
it("dry-run bypasses downgrade confirmation checks in non-interactive mode", async () => {

View File

@@ -144,6 +144,7 @@ export async function runUpdateStep(params: {
cwd?: string;
timeoutMs: number;
progress?: UpdateStepProgress;
env?: NodeJS.ProcessEnv;
}): Promise<UpdateStepResult> {
const command = params.argv.join(" ");
params.progress?.onStepStart?.({
@@ -156,6 +157,7 @@ export async function runUpdateStep(params: {
const started = Date.now();
const res = await runCommandWithTimeout(params.argv, {
cwd: params.cwd,
env: params.env,
timeoutMs: params.timeoutMs,
});
const durationMs = Date.now() - started;

View File

@@ -24,6 +24,7 @@ import {
checkUpdateStatus,
} from "../../infra/update-check.js";
import {
createGlobalInstallEnv,
cleanupGlobalRenameDirs,
globalInstallArgs,
resolveGlobalPackageRoot,
@@ -269,6 +270,7 @@ async function runPackageInstallUpdate(params: {
installKind: params.installKind,
timeoutMs: params.timeoutMs,
});
const installEnv = await createGlobalInstallEnv();
const runCommand = createGlobalCommandRunner();
const pkgRoot = await resolveGlobalPackageRoot(manager, runCommand, params.timeoutMs);
@@ -287,6 +289,7 @@ async function runPackageInstallUpdate(params: {
const updateStep = await runUpdateStep({
name: "global update",
argv: globalInstallArgs(manager, `${packageName}@${params.tag}`),
env: installEnv,
timeoutMs: params.timeoutMs,
progress: params.progress,
});
@@ -380,6 +383,7 @@ async function runGitUpdate(params: {
name: "global install",
argv: globalInstallArgs(manager, updateRoot),
cwd: updateRoot,
env: await createGlobalInstallEnv(),
timeoutMs: effectiveTimeout,
progress: params.progress,
});
@@ -835,28 +839,29 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
}
}
const result = switchToPackage
? await runPackageInstallUpdate({
root,
installKind,
tag,
timeoutMs: timeoutMs ?? 20 * 60_000,
startedAt,
progress,
})
: await runGitUpdate({
root,
switchToGit,
installKind,
timeoutMs,
startedAt,
progress,
channel,
tag,
showProgress,
opts,
stop,
});
const result =
updateInstallKind === "package"
? await runPackageInstallUpdate({
root,
installKind,
tag,
timeoutMs: timeoutMs ?? 20 * 60_000,
startedAt,
progress,
})
: await runGitUpdate({
root,
switchToGit,
installKind,
timeoutMs,
startedAt,
progress,
channel,
tag,
showProgress,
opts,
stop,
});
stop();
printResult(result, { ...opts, hideSteps: showProgress });

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathExists } from "../utils.js";
import { applyPathPrepend } from "./path-prepend.js";
export type GlobalInstallManager = "npm" | "pnpm" | "bun";
@@ -19,6 +20,60 @@ const NPM_GLOBAL_INSTALL_OMIT_OPTIONAL_FLAGS = [
...NPM_GLOBAL_INSTALL_QUIET_FLAGS,
] as const;
async function resolvePortableGitPathPrepend(
env: NodeJS.ProcessEnv | undefined,
): Promise<string[]> {
if (process.platform !== "win32") {
return [];
}
const localAppData = env?.LOCALAPPDATA?.trim() || process.env.LOCALAPPDATA?.trim();
if (!localAppData) {
return [];
}
const portableGitRoot = path.join(localAppData, "OpenClaw", "deps", "portable-git");
const candidates = [
path.join(portableGitRoot, "mingw64", "bin"),
path.join(portableGitRoot, "usr", "bin"),
path.join(portableGitRoot, "cmd"),
path.join(portableGitRoot, "bin"),
];
const existing: string[] = [];
for (const candidate of candidates) {
if (await pathExists(candidate)) {
existing.push(candidate);
}
}
return existing;
}
function applyWindowsPackageInstallEnv(env: Record<string, string>) {
if (process.platform !== "win32") {
return;
}
env.NPM_CONFIG_UPDATE_NOTIFIER = "false";
env.NPM_CONFIG_FUND = "false";
env.NPM_CONFIG_AUDIT = "false";
env.NPM_CONFIG_SCRIPT_SHELL = "cmd.exe";
env.NODE_LLAMA_CPP_SKIP_DOWNLOAD = "1";
}
export async function createGlobalInstallEnv(
env?: NodeJS.ProcessEnv,
): Promise<NodeJS.ProcessEnv | undefined> {
const pathPrepend = await resolvePortableGitPathPrepend(env);
if (pathPrepend.length === 0 && process.platform !== "win32") {
return env;
}
const merged = Object.fromEntries(
Object.entries(env ?? process.env)
.filter(([, value]) => value != null)
.map(([key, value]) => [key, String(value)]),
) as Record<string, string>;
applyPathPrepend(merged, pathPrepend);
applyWindowsPackageInstallEnv(merged);
return merged;
}
async function tryRealpath(targetPath: string): Promise<string> {
try {
return await fs.realpath(targetPath);

View File

@@ -156,12 +156,15 @@ describe("runGatewayUpdate", () => {
}
async function runWithCommand(
runCommand: (argv: string[]) => Promise<CommandResult>,
runCommand: (
argv: string[],
options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number },
) => Promise<CommandResult>,
options?: { channel?: "stable" | "beta"; tag?: string; cwd?: string },
) {
return runGatewayUpdate({
cwd: options?.cwd ?? tempDir,
runCommand: async (argv, _runOptions) => runCommand(argv),
runCommand: async (argv, runOptions) => runCommand(argv, runOptions),
timeoutMs: 5000,
...(options?.channel ? { channel: options.channel } : {}),
...(options?.tag ? { tag: options.tag } : {}),
@@ -419,6 +422,41 @@ describe("runGatewayUpdate", () => {
expect(calls.some((call) => call === expectedInstallCommand)).toBe(true);
});
it("falls back to global npm update when git is missing from PATH", async () => {
const nodeModules = path.join(tempDir, "node_modules");
const pkgRoot = path.join(nodeModules, "openclaw");
await seedGlobalPackageRoot(pkgRoot);
const calls: string[] = [];
const runCommand = async (argv: string[]): Promise<CommandResult> => {
const key = argv.join(" ");
calls.push(key);
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) {
throw Object.assign(new Error("spawn git ENOENT"), { code: "ENOENT" });
}
if (key === "npm root -g") {
return { stdout: nodeModules, stderr: "", code: 0 };
}
if (key === "pnpm root -g") {
return { stdout: "", stderr: "", code: 1 };
}
if (key === "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error") {
await fs.writeFile(
path.join(pkgRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2.0.0" }),
"utf-8",
);
}
return { stdout: "ok", stderr: "", code: 0 };
};
const result = await runWithCommand(runCommand, { cwd: pkgRoot });
expect(result.status).toBe("ok");
expect(result.mode).toBe("npm");
expect(calls).toContain("npm i -g openclaw@latest --no-fund --no-audit --loglevel=error");
});
it("cleans stale npm rename dirs before global update", async () => {
const nodeModules = path.join(tempDir, "node_modules");
const pkgRoot = path.join(nodeModules, "openclaw");
@@ -477,6 +515,74 @@ describe("runGatewayUpdate", () => {
]);
});
it("prepends portable Git PATH for global Windows npm updates", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const localAppData = path.join(tempDir, "local-app-data");
const portableGitMingw = path.join(
localAppData,
"OpenClaw",
"deps",
"portable-git",
"mingw64",
"bin",
);
const portableGitUsr = path.join(
localAppData,
"OpenClaw",
"deps",
"portable-git",
"usr",
"bin",
);
await fs.mkdir(portableGitMingw, { recursive: true });
await fs.mkdir(portableGitUsr, { recursive: true });
const nodeModules = path.join(tempDir, "node_modules");
const pkgRoot = path.join(nodeModules, "openclaw");
await seedGlobalPackageRoot(pkgRoot);
let installEnv: NodeJS.ProcessEnv | undefined;
const runCommand = async (
argv: string[],
options?: { env?: NodeJS.ProcessEnv },
): Promise<CommandResult> => {
const key = argv.join(" ");
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) {
return { stdout: "", stderr: "not a git repository", code: 128 };
}
if (key === "npm root -g") {
return { stdout: nodeModules, stderr: "", code: 0 };
}
if (key === "pnpm root -g") {
return { stdout: "", stderr: "", code: 1 };
}
if (key === "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error") {
installEnv = options?.env;
await fs.writeFile(
path.join(pkgRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2.0.0" }),
"utf-8",
);
}
return { stdout: "ok", stderr: "", code: 0 };
};
await withEnvAsync({ LOCALAPPDATA: localAppData }, async () => {
const result = await runWithCommand(runCommand, { cwd: pkgRoot });
expect(result.status).toBe("ok");
});
platformSpy.mockRestore();
const mergedPath = installEnv?.Path ?? installEnv?.PATH ?? "";
expect(mergedPath.split(path.delimiter).slice(0, 2)).toEqual([
portableGitMingw,
portableGitUsr,
]);
expect(installEnv?.NPM_CONFIG_SCRIPT_SHELL).toBe("cmd.exe");
expect(installEnv?.NODE_LLAMA_CPP_SKIP_DOWNLOAD).toBe("1");
});
it("updates global bun installs when detected", async () => {
const bunInstall = path.join(tempDir, "bun-install");
await withEnvAsync({ BUN_INSTALL: bunInstall }, async () => {

View File

@@ -22,6 +22,7 @@ import {
import { compareSemverStrings } from "./update-check.js";
import {
cleanupGlobalRenameDirs,
createGlobalInstallEnv,
detectGlobalInstallManagerForRoot,
globalInstallArgs,
globalInstallFallbackArgs,
@@ -201,7 +202,10 @@ async function resolveGitRoot(
for (const dir of candidates) {
const res = await runCommand(["git", "-C", dir, "rev-parse", "--show-toplevel"], {
timeoutMs,
});
}).catch(() => null);
if (!res) {
continue;
}
if (res.code === 0) {
const root = res.stdout.trim();
if (root) {
@@ -870,12 +874,14 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
const tag = normalizeTag(opts.tag ?? channelToNpmTag(channel));
const spec = `${packageName}@${tag}`;
const steps: UpdateStepResult[] = [];
const globalInstallEnv = await createGlobalInstallEnv();
const updateStep = await runStep({
runCommand,
name: "global update",
argv: globalInstallArgs(globalManager, spec),
cwd: pkgRoot,
timeoutMs,
env: globalInstallEnv,
progress,
stepIndex: 0,
totalSteps: 1,
@@ -892,6 +898,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
argv: fallbackArgv,
cwd: pkgRoot,
timeoutMs,
env: globalInstallEnv,
progress,
stepIndex: 0,
totalSteps: 1,