diff --git a/CHANGELOG.md b/CHANGELOG.md index 15dce06ee..21b960bc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 2fe5e8f9b..481495205 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -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 () => { diff --git a/src/cli/update-cli/shared.ts b/src/cli/update-cli/shared.ts index 8e62301e7..d7cbc5ec8 100644 --- a/src/cli/update-cli/shared.ts +++ b/src/cli/update-cli/shared.ts @@ -144,6 +144,7 @@ export async function runUpdateStep(params: { cwd?: string; timeoutMs: number; progress?: UpdateStepProgress; + env?: NodeJS.ProcessEnv; }): Promise { 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; diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 7422d43f9..8a8f3217b 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -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 { } } - 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 }); diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index 03a405b8f..3a96af1fd 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -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 { + 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) { + 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 { + 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; + applyPathPrepend(merged, pathPrepend); + applyWindowsPackageInstallEnv(merged); + return merged; +} + async function tryRealpath(targetPath: string): Promise { try { return await fs.realpath(targetPath); diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index c415e4892..1e04459f1 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -156,12 +156,15 @@ describe("runGatewayUpdate", () => { } async function runWithCommand( - runCommand: (argv: string[]) => Promise, + runCommand: ( + argv: string[], + options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number }, + ) => Promise, 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 => { + 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 => { + 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 () => { diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 5b1e31512..4c863f26e 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -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,