diff --git a/CHANGELOG.md b/CHANGELOG.md index 508676aa6dc0..fc3874957d61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Build: include `ui:build` in the `full` and `ciArtifacts` profiles of `scripts/build-all.mjs` so `pnpm build` always rebuilds `dist/control-ui` after `tsdown` cleans `dist`, removing the second-command requirement and the missing-asset failure mode for source/runtime installs and CI artifact uploads. (#85206) ### Fixes +- Install/update: bypass npm `min-release-age` policies with `--min-release-age=0` instead of `--before` so hosted installers keep working on npm versions that reject the combined config. (#84749) Thanks @TeodoroRodrigo. - WebChat: keep message-tool replies visible in the chat while still summarizing internal tool results for the model. Fixes #86347. Thanks @shakkernerd. - Agents/commitments: serialize commitment store load-modify-save writes so concurrent heartbeat and CLI updates no longer lose dismissal, sent, or attempt state. (#81153) Thanks @ai-hpc. - CLI: suppress benign self-update version-skew warnings during package post-update finalization. diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index a16c35811e8a..5408a9452185 100755 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -857,25 +857,27 @@ fix_npm_prefix_if_needed() { log "Configured npm prefix to ${target}" } -expand_npm_config_path() { - local path="$1" - if [[ -z "$path" ]]; then +resolve_npm_config_path() { + local raw="$1" + if [[ -z "$raw" || "$raw" == "null" || "$raw" == "undefined" ]]; then return 1 fi - case "$path" in - "\${HOME}/"*) path="${HOME:-}/${path#\$\{HOME\}/}" ;; - "\$HOME/"*) path="${HOME:-}/${path#\$HOME/}" ;; - [~]/*) path="${HOME:-}/${path#\~/}" ;; - esac - printf '%s\n' "$path" + if [[ "$raw" == \~/* && -n "${HOME:-}" ]]; then + printf '%s\n' "${HOME}/${raw#"~/"}" + return 0 + fi + if [[ "$raw" == "\${HOME}/"* && -n "${HOME:-}" ]]; then + printf '%s\n' "${HOME}/${raw#"\${HOME}/"}" + return 0 + fi + printf '%s\n' "$raw" } npm_config_file_has_key() { - local file - file="$(expand_npm_config_path "$1")" || return 1 + local file="$1" local key="$2" [[ -f "$file" ]] || return 1 - grep -E "^[[:space:]]*${key}[[:space:]]*=" "$file" >/dev/null 2>&1 + grep -Eiq "^[[:space:]]*${key}[[:space:]]*=" "$file" } npm_command_path() { @@ -899,36 +901,39 @@ npm_builtin_config_path() { printf '%s\n' "${npm_root}/npmrc" } -npm_raw_config_has_key() { - local key="$1" - local npm_cmd="${2:-npm}" - local user_config="${NPM_CONFIG_USERCONFIG:-${npm_config_userconfig:-}}" - local global_config="${NPM_CONFIG_GLOBALCONFIG:-${npm_config_globalconfig:-}}" - local prefix="${NPM_CONFIG_PREFIX:-${npm_config_prefix:-}}" +npm_config_has_raw_key() { + local npm_cmd="$1" + local key="$2" + local raw="" + local file="" + local -a files=() - npm_config_file_has_key ".npmrc" "$key" && return 0 - if [[ -n "$user_config" ]]; then - npm_config_file_has_key "$user_config" "$key" && return 0 + raw="${NPM_CONFIG_USERCONFIG:-${npm_config_userconfig:-}}" + if [[ -n "$raw" ]]; then + file="$(resolve_npm_config_path "$raw" 2>/dev/null || true)" + [[ -n "$file" ]] && files+=("$file") elif [[ -n "${HOME:-}" ]]; then - npm_config_file_has_key "${HOME}/.npmrc" "$key" && return 0 + files+=("${HOME}/.npmrc") fi - if [[ -n "$global_config" ]]; then - npm_config_file_has_key "$global_config" "$key" && return 0 - else - local resolved_global_config="" - resolved_global_config="$(env -u NPM_CONFIG_BEFORE -u npm_config_before "$npm_cmd" config get globalconfig 2>/dev/null || true)" - if [[ -n "$resolved_global_config" && "$resolved_global_config" != "null" && "$resolved_global_config" != "undefined" ]]; then - npm_config_file_has_key "$resolved_global_config" "$key" && return 0 + + raw="${NPM_CONFIG_GLOBALCONFIG:-${npm_config_globalconfig:-}}" + if [[ -n "$raw" ]]; then + file="$(resolve_npm_config_path "$raw" 2>/dev/null || true)" + [[ -n "$file" ]] && files+=("$file") + fi + + raw="$(env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$npm_cmd" config get globalconfig --global 2>/dev/null || true)" + file="$(resolve_npm_config_path "$raw" 2>/dev/null || true)" + [[ -n "$file" ]] && files+=("$file") + + file="$(npm_builtin_config_path "$npm_cmd" 2>/dev/null || true)" + [[ -n "$file" ]] && files+=("$file") + + for file in "${files[@]}"; do + if npm_config_file_has_key "$file" "$key"; then + return 0 fi - fi - if [[ -n "$prefix" ]]; then - npm_config_file_has_key "${prefix}/etc/npmrc" "$key" && return 0 - fi - local builtin_config="" - builtin_config="$(npm_builtin_config_path "$npm_cmd" 2>/dev/null || true)" - if [[ -n "$builtin_config" ]]; then - npm_config_file_has_key "$builtin_config" "$key" && return 0 - fi + done return 1 } @@ -939,10 +944,12 @@ install_openclaw() { fi local freshness_flag="--min-release-age=0" local min_release_age="" - min_release_age="$(env -u NPM_CONFIG_BEFORE -u npm_config_before "$(npm_bin)" config get min-release-age 2>/dev/null || true)" - if ! npm_raw_config_has_key "min-release-age" "$(npm_bin)" && [[ -z "$min_release_age" || "$min_release_age" == "null" || "$min_release_age" == "undefined" ]]; then + min_release_age="$(env -u NPM_CONFIG_BEFORE -u npm_config_before "$(npm_bin)" config get min-release-age --global 2>/dev/null || true)" + if npm_config_has_raw_key "$(npm_bin)" "min-release-age"; then + freshness_flag="--min-release-age=0" + elif [[ -z "$min_release_age" || "$min_release_age" == "null" || "$min_release_age" == "undefined" ]]; then local before_value="" - before_value="$(env -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" config get before 2>/dev/null || true)" + before_value="$(env -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" config get before --global 2>/dev/null || true)" if [[ -n "$before_value" && "$before_value" != "null" && "$before_value" != "undefined" ]]; then freshness_flag="--before=$(date -u '+%Y-%m-%dT%H:%M:%S.000Z')" fi diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 0c50851c611f..c2595e1a0423 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -930,6 +930,63 @@ function Test-OpenClawSourcePackageInstallSpec { ) } +function Resolve-NpmConfigPath { + param([string]$RawPath) + if ([string]::IsNullOrWhiteSpace($RawPath) -or $RawPath -eq "null" -or $RawPath -eq "undefined") { + return $null + } + if (($RawPath.StartsWith("~/") -or $RawPath.StartsWith("~\")) -and -not [string]::IsNullOrWhiteSpace($HOME)) { + return (Join-Path $HOME $RawPath.Substring(2)) + } + if (($RawPath.StartsWith('${HOME}/') -or $RawPath.StartsWith('${HOME}\')) -and -not [string]::IsNullOrWhiteSpace($HOME)) { + return (Join-Path $HOME $RawPath.Substring(8)) + } + return $RawPath +} + +function Test-NpmConfigFileKey { + param( + [string]$Path, + [string]$Key + ) + if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) { + return $false + } + $escapedKey = [regex]::Escape($Key) + return [bool](Select-String -LiteralPath $Path -Pattern "^\s*$escapedKey\s*=" -Quiet) +} + +function Test-NpmConfigRawKey { + param([string]$Key) + $files = New-Object System.Collections.Generic.List[string] + $userConfig = if ($env:NPM_CONFIG_USERCONFIG) { $env:NPM_CONFIG_USERCONFIG } else { $env:npm_config_userconfig } + if ($userConfig) { + $resolvedUserConfig = Resolve-NpmConfigPath $userConfig + if ($resolvedUserConfig) { $files.Add($resolvedUserConfig) } + } elseif (-not [string]::IsNullOrWhiteSpace($HOME)) { + $files.Add((Join-Path $HOME ".npmrc")) + } + + $globalConfig = if ($env:NPM_CONFIG_GLOBALCONFIG) { $env:NPM_CONFIG_GLOBALCONFIG } else { $env:npm_config_globalconfig } + if ($globalConfig) { + $resolvedGlobalConfig = Resolve-NpmConfigPath $globalConfig + if ($resolvedGlobalConfig) { $files.Add($resolvedGlobalConfig) } + } + + $detectedGlobalConfig = (Invoke-NpmCommand -Arguments @("config", "get", "globalconfig", "--global") 2>$null) + if ($LASTEXITCODE -eq 0) { + $resolvedDetectedGlobalConfig = Resolve-NpmConfigPath $detectedGlobalConfig + if ($resolvedDetectedGlobalConfig) { $files.Add($resolvedDetectedGlobalConfig) } + } + + foreach ($file in ($files | Select-Object -Unique)) { + if (Test-NpmConfigFileKey -Path $file -Key $Key) { + return $true + } + } + return $false +} + function Install-OpenClaw { if ([string]::IsNullOrWhiteSpace($Tag)) { $Tag = "latest" @@ -951,9 +1008,12 @@ function Install-OpenClaw { $installSpec = Resolve-NpmOpenClawInstallSpec -PackageName $packageName -RequestedTag $Tag Write-Host "[*] Installing OpenClaw ($installSpec)..." -ForegroundColor Yellow $freshnessArgs = @("--min-release-age=0") - $minReleaseAge = (Invoke-NpmCommand -Arguments @("config", "get", "min-release-age") 2>$null) - if ($LASTEXITCODE -ne 0 -or -not $minReleaseAge -or $minReleaseAge.Trim() -eq "null" -or $minReleaseAge.Trim() -eq "undefined") { - $beforeValue = (Invoke-NpmCommand -Arguments @("config", "get", "before") 2>$null) + $minReleaseAge = (Invoke-NpmCommand -Arguments @("config", "get", "min-release-age", "--global") 2>$null) + $minReleaseAgeStatus = $LASTEXITCODE + if (Test-NpmConfigRawKey -Key "min-release-age") { + $freshnessArgs = @("--min-release-age=0") + } elseif ($minReleaseAgeStatus -ne 0 -or -not $minReleaseAge -or $minReleaseAge.Trim() -eq "null" -or $minReleaseAge.Trim() -eq "undefined") { + $beforeValue = (Invoke-NpmCommand -Arguments @("config", "get", "before", "--global") 2>$null) if ($LASTEXITCODE -eq 0 -and $beforeValue -and $beforeValue.Trim() -ne "null" -and $beforeValue.Trim() -ne "undefined") { $freshnessArgs = @("--before=$((Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ"))") } diff --git a/scripts/install.sh b/scripts/install.sh index 878d1fb82126..91412cbbfbfe 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -744,25 +744,27 @@ auto_install_build_tools_for_npm_failure() { return 0 } -expand_npm_config_path() { - local path="$1" - if [[ -z "$path" ]]; then +resolve_npm_config_path() { + local raw="$1" + if [[ -z "$raw" || "$raw" == "null" || "$raw" == "undefined" ]]; then return 1 fi - case "$path" in - "\${HOME}/"*) path="${HOME:-}/${path#\$\{HOME\}/}" ;; - "\$HOME/"*) path="${HOME:-}/${path#\$HOME/}" ;; - [~]/*) path="${HOME:-}/${path#\~/}" ;; - esac - printf '%s\n' "$path" + if [[ "$raw" == \~/* && -n "${HOME:-}" ]]; then + printf '%s\n' "${HOME}/${raw#"~/"}" + return 0 + fi + if [[ "$raw" == "\${HOME}/"* && -n "${HOME:-}" ]]; then + printf '%s\n' "${HOME}/${raw#"\${HOME}/"}" + return 0 + fi + printf '%s\n' "$raw" } npm_config_file_has_key() { - local file - file="$(expand_npm_config_path "$1")" || return 1 + local file="$1" local key="$2" [[ -f "$file" ]] || return 1 - grep -E "^[[:space:]]*${key}[[:space:]]*=" "$file" >/dev/null 2>&1 + grep -Eiq "^[[:space:]]*${key}[[:space:]]*=" "$file" } npm_command_path() { @@ -786,36 +788,39 @@ npm_builtin_config_path() { printf '%s\n' "${npm_root}/npmrc" } -npm_raw_config_has_key() { - local key="$1" - local npm_cmd="${2:-npm}" - local user_config="${NPM_CONFIG_USERCONFIG:-${npm_config_userconfig:-}}" - local global_config="${NPM_CONFIG_GLOBALCONFIG:-${npm_config_globalconfig:-}}" - local prefix="${NPM_CONFIG_PREFIX:-${npm_config_prefix:-}}" +npm_config_has_raw_key() { + local npm_cmd="$1" + local key="$2" + local raw="" + local file="" + local -a files=() - npm_config_file_has_key ".npmrc" "$key" && return 0 - if [[ -n "$user_config" ]]; then - npm_config_file_has_key "$user_config" "$key" && return 0 + raw="${NPM_CONFIG_USERCONFIG:-${npm_config_userconfig:-}}" + if [[ -n "$raw" ]]; then + file="$(resolve_npm_config_path "$raw" 2>/dev/null || true)" + [[ -n "$file" ]] && files+=("$file") elif [[ -n "${HOME:-}" ]]; then - npm_config_file_has_key "${HOME}/.npmrc" "$key" && return 0 + files+=("${HOME}/.npmrc") fi - if [[ -n "$global_config" ]]; then - npm_config_file_has_key "$global_config" "$key" && return 0 - else - local resolved_global_config="" - resolved_global_config="$(env -u NPM_CONFIG_BEFORE -u npm_config_before "$npm_cmd" config get globalconfig 2>/dev/null || true)" - if [[ -n "$resolved_global_config" && "$resolved_global_config" != "null" && "$resolved_global_config" != "undefined" ]]; then - npm_config_file_has_key "$resolved_global_config" "$key" && return 0 + + raw="${NPM_CONFIG_GLOBALCONFIG:-${npm_config_globalconfig:-}}" + if [[ -n "$raw" ]]; then + file="$(resolve_npm_config_path "$raw" 2>/dev/null || true)" + [[ -n "$file" ]] && files+=("$file") + fi + + raw="$(env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$npm_cmd" config get globalconfig --global 2>/dev/null || true)" + file="$(resolve_npm_config_path "$raw" 2>/dev/null || true)" + [[ -n "$file" ]] && files+=("$file") + + file="$(npm_builtin_config_path "$npm_cmd" 2>/dev/null || true)" + [[ -n "$file" ]] && files+=("$file") + + for file in "${files[@]}"; do + if npm_config_file_has_key "$file" "$key"; then + return 0 fi - fi - if [[ -n "$prefix" ]]; then - npm_config_file_has_key "${prefix}/etc/npmrc" "$key" && return 0 - fi - local builtin_config="" - builtin_config="$(npm_builtin_config_path "$npm_cmd" 2>/dev/null || true)" - if [[ -n "$builtin_config" ]]; then - npm_config_file_has_key "$builtin_config" "$key" && return 0 - fi + done return 1 } @@ -825,10 +830,12 @@ run_npm_global_install() { local freshness_flag="--min-release-age=0" local min_release_age="" - min_release_age="$(env -u NPM_CONFIG_BEFORE -u npm_config_before npm config get min-release-age 2>/dev/null || true)" - if ! npm_raw_config_has_key "min-release-age" "npm" && [[ -z "$min_release_age" || "$min_release_age" == "null" || "$min_release_age" == "undefined" ]]; then + min_release_age="$(env -u NPM_CONFIG_BEFORE -u npm_config_before npm config get min-release-age --global 2>/dev/null || true)" + if npm_config_has_raw_key npm "min-release-age"; then + freshness_flag="--min-release-age=0" + elif [[ -z "$min_release_age" || "$min_release_age" == "null" || "$min_release_age" == "undefined" ]]; then local before_value="" - before_value="$(env -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age npm config get before 2>/dev/null || true)" + before_value="$(env -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age npm config get before --global 2>/dev/null || true)" if [[ -n "$before_value" && "$before_value" != "null" && "$before_value" != "undefined" ]]; then freshness_flag="--before=$(date -u '+%Y-%m-%dT%H:%M:%S.000Z')" fi diff --git a/src/infra/npm-install-env.test.ts b/src/infra/npm-install-env.test.ts index afdf1b5cb891..412a4e30120f 100644 --- a/src/infra/npm-install-env.test.ts +++ b/src/infra/npm-install-env.test.ts @@ -41,6 +41,17 @@ function expectUnsetOrZeroNpmJsonConfig(value: unknown): void { expect(value == null || value === false || value === 0 || value === "0").toBe(true); } +function createIsolatedNpmConfigEnv(dir: string): NodeJS.ProcessEnv { + const home = path.join(dir, "home"); + const globalconfig = path.join(dir, "global-npmrc"); + fsSync.mkdirSync(home, { recursive: true }); + fsSync.writeFileSync(globalconfig, "", "utf-8"); + return { + HOME: home, + NPM_CONFIG_GLOBALCONFIG: globalconfig, + }; +} + describe("npm project install env", () => { it("uses an absolute POSIX script shell for npm lifecycle scripts", () => { withMockedPlatform("linux", () => { @@ -163,10 +174,12 @@ describe("npm project install env", () => { it("uses a current before override for explicit npm before policy", () => { const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-npmrc-")); try { + const baseEnv = createIsolatedNpmConfigEnv(dir); const npmrc = path.join(dir, "npmrc"); fsSync.writeFileSync(npmrc, "before=2026-01-01T00:00:00.000Z\n", "utf-8"); const env = createNpmProjectInstallEnv( { + ...baseEnv, NPM_CONFIG_USERCONFIG: npmrc, }, {}, @@ -180,6 +193,7 @@ describe("npm project install env", () => { const envWithParentAge = createNpmProjectInstallEnv( { + ...baseEnv, NPM_CONFIG_USERCONFIG: npmrc, NPM_CONFIG_MIN_RELEASE_AGE: "7", }, @@ -200,12 +214,14 @@ describe("npm project install env", () => { it("uses before args for stale npm before policies", () => { const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-npmrc-")); try { + const baseEnv = createIsolatedNpmConfigEnv(dir); const npmrc = path.join(dir, "npmrc"); fsSync.writeFileSync(npmrc, "before=2026-01-01T00:00:00.000Z\n", "utf-8"); expect( createNpmFreshnessBypassArgs( { + ...baseEnv, NPM_CONFIG_USERCONFIG: npmrc, }, FROZEN_NOW, @@ -219,11 +235,13 @@ describe("npm project install env", () => { it("uses before args for expanded npm userconfig paths", () => { const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-home-npmrc-")); try { + const baseEnv = createIsolatedNpmConfigEnv(dir); fsSync.writeFileSync(path.join(dir, ".npmrc"), "before=2026-01-01T00:00:00.000Z\n", "utf-8"); expect( createNpmFreshnessBypassArgs( { + ...baseEnv, HOME: dir, NPM_CONFIG_USERCONFIG: "~/.npmrc", }, @@ -233,6 +251,7 @@ describe("npm project install env", () => { expect( createNpmFreshnessBypassArgs( { + ...baseEnv, HOME: dir, NPM_CONFIG_USERCONFIG: "${HOME}/.npmrc", }, @@ -247,7 +266,9 @@ describe("npm project install env", () => { it("uses before args for npm default globalconfig before policies", () => { const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-npm-prefix-")); try { + const home = path.join(dir, "home"); const npmrcDir = path.join(dir, "etc"); + fsSync.mkdirSync(home, { recursive: true }); fsSync.mkdirSync(npmrcDir, { recursive: true }); fsSync.writeFileSync( path.join(npmrcDir, "npmrc"), @@ -258,6 +279,7 @@ describe("npm project install env", () => { expect( createNpmFreshnessBypassArgs( { + HOME: home, NPM_CONFIG_PREFIX: dir, }, FROZEN_NOW, @@ -271,13 +293,14 @@ describe("npm project install env", () => { it("uses before args for command project npmrc before policies", () => { const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-project-npmrc-")); try { + const baseEnv = createIsolatedNpmConfigEnv(dir); fsSync.writeFileSync(path.join(dir, ".npmrc"), "before=2026-01-01T00:00:00.000Z\n", "utf-8"); - expect(createNpmFreshnessBypassArgs({}, FROZEN_NOW, { npmConfigCwd: dir })).toEqual([ + expect(createNpmFreshnessBypassArgs(baseEnv, FROZEN_NOW, { npmConfigCwd: dir })).toEqual([ `--before=${FROZEN_NOW.toISOString()}`, ]); - const env = createNpmProjectInstallEnv({}, { npmConfigCwd: dir }, FROZEN_NOW); + const env = createNpmProjectInstallEnv(baseEnv, { npmConfigCwd: dir }, FROZEN_NOW); expect(env.npm_config_min_release_age).toBe(""); expect(env.npm_config_before).toBe(FROZEN_NOW.toISOString()); } finally { @@ -288,10 +311,11 @@ describe("npm project install env", () => { it("uses before args for the current project npmrc by default", () => { const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-current-npmrc-")); try { + const baseEnv = createIsolatedNpmConfigEnv(dir); fsSync.writeFileSync(path.join(dir, ".npmrc"), "before=2026-01-01T00:00:00.000Z\n", "utf-8"); const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(dir); withRestoredMocks([cwdSpy], () => { - expect(createNpmFreshnessBypassArgs({}, FROZEN_NOW)).toEqual([ + expect(createNpmFreshnessBypassArgs(baseEnv, FROZEN_NOW)).toEqual([ `--before=${FROZEN_NOW.toISOString()}`, ]); }); @@ -303,6 +327,7 @@ describe("npm project install env", () => { it("uses before args for scoped npm prefix before policies", () => { const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-prefix-npmrc-")); try { + const baseEnv = createIsolatedNpmConfigEnv(dir); const npmrcDir = path.join(dir, "etc"); fsSync.mkdirSync(npmrcDir, { recursive: true }); fsSync.writeFileSync( @@ -311,7 +336,7 @@ describe("npm project install env", () => { "utf-8", ); - expect(createNpmFreshnessBypassArgs({}, FROZEN_NOW, { npmConfigPrefix: dir })).toEqual([ + expect(createNpmFreshnessBypassArgs(baseEnv, FROZEN_NOW, { npmConfigPrefix: dir })).toEqual([ `--before=${FROZEN_NOW.toISOString()}`, ]); } finally { @@ -322,10 +347,12 @@ describe("npm project install env", () => { it("overrides stale npmrc before config without emitting release-age config", () => { const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-npmrc-")); try { + const baseEnv = createIsolatedNpmConfigEnv(dir); const npmrc = path.join(dir, "npmrc"); fsSync.writeFileSync(npmrc, "before=2026-01-01T00:00:00.000Z\n", "utf-8"); const env = createNpmProjectInstallEnv( { + ...baseEnv, NPM_CONFIG_USERCONFIG: npmrc, }, {}, diff --git a/test/scripts/install-cli.test.ts b/test/scripts/install-cli.test.ts index a4c6172b65d6..0796ecd069b4 100644 --- a/test/scripts/install-cli.test.ts +++ b/test/scripts/install-cli.test.ts @@ -1,5 +1,15 @@ import { spawnSync } from "node:child_process"; -import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { + chmodSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -23,6 +33,74 @@ function linkRequiredShellTools(bin: string) { } } +function writeNpmFreshnessConflictFixture(path: string, argsLog: string) { + writeFileSync( + path, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `printf '%s\\n' "$*" >> ${JSON.stringify(argsLog)}`, + 'if [[ "$1" == "config" && "$2" == "get" && "$3" == "min-release-age" ]]; then', + " printf 'null\\n'", + " exit 0", + "fi", + 'if [[ "$1" == "config" && "$2" == "get" && "$3" == "before" ]]; then', + " printf 'Wed May 13 2026 21:25:20 GMT-0300 (Brasilia Standard Time)\\n'", + " exit 0", + "fi", + 'for arg in "$@"; do', + ' if [[ "$arg" == --before=* ]]; then', + " printf '%s\\n' 'Exit prior to config file resolving' >&2", + " printf '%s\\n' 'cause' >&2", + " printf '%s\\n' '--min-release-age cannot be provided when using --before' >&2", + " exit 64", + " fi", + "done", + 'for arg in "$@"; do', + ' if [[ "$arg" == "--min-release-age=0" ]]; then', + " exit 0", + " fi", + "done", + "exit 65", + "", + ].join("\n"), + ); + chmodSync(path, 0o755); +} + +function writeNpmBeforePolicyFixture(path: string, argsLog: string) { + writeFileSync( + path, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `printf '%s\\n' "$*" >> ${JSON.stringify(argsLog)}`, + 'if [[ "$1" == "config" && "$2" == "get" && "$3" == "min-release-age" ]]; then', + " printf 'null\\n'", + " exit 0", + "fi", + 'if [[ "$1" == "config" && "$2" == "get" && "$3" == "before" ]]; then', + " printf 'Wed May 13 2026 21:25:20 GMT-0300 (Brasilia Standard Time)\\n'", + " exit 0", + "fi", + 'for arg in "$@"; do', + ' if [[ "$arg" == "--min-release-age=0" ]]; then', + " printf '%s\\n' 'min-release-age should not be selected for project-only npmrc' >&2", + " exit 64", + " fi", + "done", + 'for arg in "$@"; do', + ' if [[ "$arg" == --before=* ]]; then', + " exit 0", + " fi", + "done", + "exit 65", + "", + ].join("\n"), + ); + chmodSync(path, 0o755); +} + describe("install-cli.sh", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); @@ -144,12 +222,7 @@ describe("install-cli.sh", () => { linkRequiredShellTools(bin); writeFileSync( fakeApk, - [ - "#!/bin/bash", - 'printf "%s\\n" "$*" >> "$APK_LOG"', - "exit 99", - "", - ].join("\n"), + ["#!/bin/bash", 'printf "%s\\n" "$*" >> "$APK_LOG"', "exit 99", ""].join("\n"), ); writeFileSync( fakeNode, @@ -166,14 +239,7 @@ describe("install-cli.sh", () => { "", ].join("\n"), ); - writeFileSync( - fakeNpm, - [ - "#!/bin/bash", - "exit 0", - "", - ].join("\n"), - ); + writeFileSync(fakeNpm, ["#!/bin/bash", "exit 0", ""].join("\n")); chmodSync(fakeApk, 0o755); chmodSync(fakeNode, 0o755); chmodSync(fakeNpm, 0o755); @@ -233,12 +299,7 @@ describe("install-cli.sh", () => { mkdirSync(nodePrefixBin, { recursive: true }); writeFileSync( fakeApk, - [ - "#!/bin/bash", - 'printf "%s\\n" "$*" >> "$APK_LOG"', - "exit 99", - "", - ].join("\n"), + ["#!/bin/bash", 'printf "%s\\n" "$*" >> "$APK_LOG"', "exit 99", ""].join("\n"), ); writeFileSync( staleNode, @@ -285,22 +346,8 @@ describe("install-cli.sh", () => { "", ].join("\n"), ); - writeFileSync( - oldNpm, - [ - "#!/bin/bash", - "exit 0", - "", - ].join("\n"), - ); - writeFileSync( - fakeNpm, - [ - "#!/bin/bash", - "exit 0", - "", - ].join("\n"), - ); + writeFileSync(oldNpm, ["#!/bin/bash", "exit 0", ""].join("\n")); + writeFileSync(fakeNpm, ["#!/bin/bash", "exit 0", ""].join("\n")); chmodSync(fakeApk, 0o755); chmodSync(staleNode, 0o755); chmodSync(oldNode, 0o755); @@ -384,14 +431,7 @@ describe("install-cli.sh", () => { "", ].join("\n"), ); - writeFileSync( - fakeNpm, - [ - "#!/bin/bash", - "exit 0", - "", - ].join("\n"), - ); + writeFileSync(fakeNpm, ["#!/bin/bash", "exit 0", ""].join("\n")); chmodSync(fakeApk, 0o755); chmodSync(fakeNode, 0o755); chmodSync(fakeNpm, 0o755); @@ -446,12 +486,7 @@ describe("install-cli.sh", () => { linkRequiredShellTools(bin); writeFileSync( fakeApk, - [ - "#!/bin/bash", - 'printf "%s\\n" "$*" >> "$APK_LOG"', - "exit 0", - "", - ].join("\n"), + ["#!/bin/bash", 'printf "%s\\n" "$*" >> "$APK_LOG"', "exit 0", ""].join("\n"), ); writeFileSync( fakeNode, @@ -468,14 +503,7 @@ describe("install-cli.sh", () => { "", ].join("\n"), ); - writeFileSync( - fakeNpm, - [ - "#!/bin/bash", - "exit 0", - "", - ].join("\n"), - ); + writeFileSync(fakeNpm, ["#!/bin/bash", "exit 0", ""].join("\n")); chmodSync(fakeApk, 0o755); chmodSync(fakeNode, 0o755); chmodSync(fakeNpm, 0o755); @@ -504,7 +532,9 @@ describe("install-cli.sh", () => { expect(result.status).toBe(1); expect(readFileSync(apkLog, "utf8")).toContain("add --no-cache nodejs npm"); - expect(result.stdout).toContain("Alpine Node package must provide Node >= 22.22.0 with node:sqlite"); + expect(result.stdout).toContain( + "Alpine Node package must provide Node >= 22.22.0 with node:sqlite", + ); expect(result.stdout).toContain("found v22.18.0"); } finally { rmSync(tmp, { force: true, recursive: true }); @@ -513,7 +543,7 @@ describe("install-cli.sh", () => { it("clears npm freshness filters for package installs", () => { expect(script).toContain('freshness_flag="--min-release-age=0"'); - expect(script).toContain('npm_raw_config_has_key "min-release-age"'); + expect(script).toContain('npm_config_has_raw_key "$(npm_bin)" "min-release-age"'); expect(script).toContain('freshness_flag="--before=$(date -u'); expect(script).toContain("env -u NPM_CONFIG_BEFORE -u npm_config_before"); }); @@ -748,4 +778,78 @@ describe("install-cli.sh", () => { expect(result.stdout).toContain("npm installs do not support OpenClaw GitHub source targets"); expect(result.stdout).toContain("--install-method git --version main"); }); + + it("does not emit before args when npmrc min-release-age computes a before cutoff", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-freshness-")); + const prefix = join(tmp, "prefix"); + const home = join(tmp, "home"); + const nodeBin = join(prefix, "tools/node-v22.22.0/bin"); + const argsLog = join(tmp, "npm-args.log"); + mkdirSync(nodeBin, { recursive: true }); + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, ".npmrc"), "min-release-age=7\n"); + writeNpmFreshnessConflictFixture(join(nodeBin, "npm"), argsLog); + + let result: ReturnType | undefined; + let argsOutput = ""; + try { + result = runInstallCliShell( + [ + "set -euo pipefail", + `HOME=${JSON.stringify(home)}`, + `OPENCLAW_PREFIX=${JSON.stringify(prefix)}`, + "OPENCLAW_VERSION=2026.5.19", + `source ${JSON.stringify(SCRIPT_PATH)}`, + "ensure_git() { return 0; }", + "install_openclaw", + ].join("\n"), + ); + argsOutput = readFileSync(argsLog, "utf8"); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + + expect(result?.status).toBe(0); + expect(argsOutput).toContain("--min-release-age=0"); + expect(argsOutput).not.toContain("--before="); + }); + + it("ignores project npmrc when choosing global install freshness args", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-global-freshness-")); + const prefix = join(tmp, "prefix"); + const home = join(tmp, "home"); + const project = join(tmp, "project"); + const nodeBin = join(prefix, "tools/node-v22.22.0/bin"); + const argsLog = join(tmp, "npm-args.log"); + mkdirSync(nodeBin, { recursive: true }); + mkdirSync(home, { recursive: true }); + mkdirSync(project, { recursive: true }); + writeFileSync(join(home, ".npmrc"), "before=2026-01-01T00:00:00.000Z\n"); + writeFileSync(join(project, ".npmrc"), "min-release-age=7\n"); + writeNpmBeforePolicyFixture(join(nodeBin, "npm"), argsLog); + + let result: ReturnType | undefined; + let argsOutput = ""; + try { + result = runInstallCliShell( + [ + "set -euo pipefail", + `cd ${JSON.stringify(project)}`, + `HOME=${JSON.stringify(home)}`, + `OPENCLAW_PREFIX=${JSON.stringify(prefix)}`, + "OPENCLAW_VERSION=2026.5.19", + `source ${JSON.stringify(process.cwd() + "/" + SCRIPT_PATH)}`, + "ensure_git() { return 0; }", + "install_openclaw", + ].join("\n"), + ); + argsOutput = readFileSync(argsLog, "utf8"); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + + expect(result?.status).toBe(0); + expect(argsOutput).toContain("--before="); + expect(argsOutput).not.toContain("--min-release-age=0"); + }); }); diff --git a/test/scripts/install-ps1.test.ts b/test/scripts/install-ps1.test.ts index d20829230f4b..db5e5bfa3676 100644 --- a/test/scripts/install-ps1.test.ts +++ b/test/scripts/install-ps1.test.ts @@ -152,6 +152,30 @@ describe("install.ps1 failure handling", () => { expect(npmInstallBody).toContain("-InstallMethod git -Tag main"); }); + it("does not read project npmrc when choosing global install freshness args", () => { + const rawKeyBody = extractFunctionBody(source, "Test-NpmConfigRawKey"); + expect(rawKeyBody).not.toContain("Get-Location"); + expect(rawKeyBody).not.toContain('Join-Path (Get-Location) ".npmrc"'); + }); + + it("preserves the min-release-age probe status before raw npmrc detection", () => { + const npmInstallBody = extractFunctionBody(source, "Install-OpenClaw"); + const probeStatusCapture = npmInstallBody.indexOf("$minReleaseAgeStatus = $LASTEXITCODE"); + const rawKeyProbe = npmInstallBody.indexOf("Test-NpmConfigRawKey -Key"); + expect(probeStatusCapture).toBeGreaterThan(-1); + expect(rawKeyProbe).toBeGreaterThan(-1); + expect(probeStatusCapture).toBeLessThan(rawKeyProbe); + expect(npmInstallBody).toContain( + "} elseif ($minReleaseAgeStatus -ne 0 -or -not $minReleaseAge", + ); + expect(npmInstallBody).toContain( + 'Invoke-NpmCommand -Arguments @("config", "get", "min-release-age", "--global")', + ); + expect(npmInstallBody).toContain( + 'Invoke-NpmCommand -Arguments @("config", "get", "before", "--global")', + ); + }); + it("preserves caller-relative local tarball install specs before safe-cwd npm calls", () => { const resolveSpecBody = extractFunctionBody(source, "Resolve-NpmOpenClawInstallSpec"); const localSpecBody = extractFunctionBody(source, "Resolve-LocalNpmPackageInstallSpec"); diff --git a/test/scripts/install-sh.test.ts b/test/scripts/install-sh.test.ts index 27aa5c1abee6..314f956f4443 100644 --- a/test/scripts/install-sh.test.ts +++ b/test/scripts/install-sh.test.ts @@ -17,6 +17,74 @@ function runInstallShell(script: string, env: NodeJS.ProcessEnv = {}) { }); } +function writeNpmFreshnessConflictFixture(path: string, argsLog: string) { + writeFileSync( + path, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `printf '%s\\n' "$*" >> ${JSON.stringify(argsLog)}`, + 'if [[ "$1" == "config" && "$2" == "get" && "$3" == "min-release-age" ]]; then', + " printf 'null\\n'", + " exit 0", + "fi", + 'if [[ "$1" == "config" && "$2" == "get" && "$3" == "before" ]]; then', + " printf 'Wed May 13 2026 21:25:20 GMT-0300 (Brasilia Standard Time)\\n'", + " exit 0", + "fi", + 'for arg in "$@"; do', + ' if [[ "$arg" == --before=* ]]; then', + " printf '%s\\n' 'Exit prior to config file resolving' >&2", + " printf '%s\\n' 'cause' >&2", + " printf '%s\\n' '--min-release-age cannot be provided when using --before' >&2", + " exit 64", + " fi", + "done", + 'for arg in "$@"; do', + ' if [[ "$arg" == "--min-release-age=0" ]]; then', + " exit 0", + " fi", + "done", + "exit 65", + "", + ].join("\n"), + ); + chmodSync(path, 0o755); +} + +function writeNpmBeforePolicyFixture(path: string, argsLog: string) { + writeFileSync( + path, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `printf '%s\\n' "$*" >> ${JSON.stringify(argsLog)}`, + 'if [[ "$1" == "config" && "$2" == "get" && "$3" == "min-release-age" ]]; then', + " printf 'null\\n'", + " exit 0", + "fi", + 'if [[ "$1" == "config" && "$2" == "get" && "$3" == "before" ]]; then', + " printf 'Wed May 13 2026 21:25:20 GMT-0300 (Brasilia Standard Time)\\n'", + " exit 0", + "fi", + 'for arg in "$@"; do', + ' if [[ "$arg" == "--min-release-age=0" ]]; then', + " printf '%s\\n' 'min-release-age should not be selected for project-only npmrc' >&2", + " exit 64", + " fi", + "done", + 'for arg in "$@"; do', + ' if [[ "$arg" == --before=* ]]; then', + " exit 0", + " fi", + "done", + "exit 65", + "", + ].join("\n"), + ); + chmodSync(path, 0o755); +} + describe("install.sh", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); @@ -42,15 +110,13 @@ describe("install.sh", () => { it("installs Node.js with apk on Alpine before falling back to NodeSource", () => { expect(script).toContain("finish_linux_node_install()"); expect(script).toContain('ui_info "Installing Node.js via apk (Alpine Linux detected)"'); - expect(script).toContain( - 'run_quiet_step "Installing Node.js" apk add --no-cache nodejs npm', - ); + expect(script).toContain('run_quiet_step "Installing Node.js" apk add --no-cache nodejs npm'); expect(script).toContain( 'run_quiet_step "Installing Node.js" sudo apk add --no-cache nodejs npm', ); - expect(script).toContain('if ! node_is_at_least_required; then'); + expect(script).toContain("if ! node_is_at_least_required; then"); - const apkIndex = script.indexOf('if command -v apk &> /dev/null; then'); + const apkIndex = script.indexOf("if command -v apk &> /dev/null; then"); const nodeSourceIndex = script.indexOf('ui_info "Installing Node.js via NodeSource"'); expect(apkIndex).toBeGreaterThan(-1); expect(nodeSourceIndex).toBeGreaterThan(apkIndex); @@ -82,7 +148,7 @@ describe("install.sh", () => { it("clears npm freshness filters for package installs", () => { expect(script).toContain("env -u NPM_CONFIG_BEFORE -u npm_config_before"); expect(script).toContain('freshness_flag="--min-release-age=0"'); - expect(script).toContain('npm_raw_config_has_key "min-release-age"'); + expect(script).toContain('npm_config_has_raw_key npm "min-release-age"'); expect(script).toContain('freshness_flag="--before=$(date -u'); expect(script).toContain('cmd+=(--no-fund --no-audit "$freshness_flag" install -g "$spec")'); }); @@ -386,6 +452,80 @@ describe("install.sh", () => { expect(result.stdout).toContain("--install-method git --version main"); }); + it("does not emit before args when npmrc min-release-age computes a before cutoff", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-npm-freshness-")); + const bin = join(tmp, "bin"); + const home = join(tmp, "home"); + const argsLog = join(tmp, "npm-args.log"); + mkdirSync(bin, { recursive: true }); + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, ".npmrc"), "min-release-age=7\n"); + writeNpmFreshnessConflictFixture(join(bin, "npm"), argsLog); + + let result: ReturnType | undefined; + let argsOutput = ""; + try { + result = runInstallShell( + [ + "set -euo pipefail", + `source ${JSON.stringify(SCRIPT_PATH)}`, + `HOME=${JSON.stringify(home)}`, + `PATH=${JSON.stringify(`${bin}:/usr/bin:/bin`)}`, + "NPM_LOGLEVEL=error", + "NPM_SILENT_FLAG=", + "SHARP_IGNORE_GLOBAL_LIBVIPS=1", + `run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`, + ].join("\n"), + ); + argsOutput = readFileSync(argsLog, "utf8"); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + + expect(result?.status).toBe(0); + expect(argsOutput).toContain("--min-release-age=0"); + expect(argsOutput).not.toContain("--before="); + }); + + it("ignores project npmrc when choosing global install freshness args", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-global-freshness-")); + const bin = join(tmp, "bin"); + const home = join(tmp, "home"); + const project = join(tmp, "project"); + const argsLog = join(tmp, "npm-args.log"); + mkdirSync(bin, { recursive: true }); + mkdirSync(home, { recursive: true }); + mkdirSync(project, { recursive: true }); + writeFileSync(join(home, ".npmrc"), "before=2026-01-01T00:00:00.000Z\n"); + writeFileSync(join(project, ".npmrc"), "min-release-age=7\n"); + writeNpmBeforePolicyFixture(join(bin, "npm"), argsLog); + + let result: ReturnType | undefined; + let argsOutput = ""; + try { + result = runInstallShell( + [ + "set -euo pipefail", + `cd ${JSON.stringify(project)}`, + `source ${JSON.stringify(process.cwd() + "/" + SCRIPT_PATH)}`, + `HOME=${JSON.stringify(home)}`, + `PATH=${JSON.stringify(`${bin}:/usr/bin:/bin`)}`, + "NPM_LOGLEVEL=error", + "NPM_SILENT_FLAG=", + "SHARP_IGNORE_GLOBAL_LIBVIPS=1", + `run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`, + ].join("\n"), + ); + argsOutput = readFileSync(argsLog, "utf8"); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + + expect(result?.status).toBe(0); + expect(argsOutput).toContain("--before="); + expect(argsOutput).not.toContain("--min-release-age=0"); + }); + it("exports noninteractive apt env during Linux startup", () => { expect(script).toMatch( /detect_os_or_die\s+if \[\[ "\$OS" == "linux" \]\]; then\s+export DEBIAN_FRONTEND="\$\{DEBIAN_FRONTEND:-noninteractive\}"\s+export NEEDRESTART_MODE="\$\{NEEDRESTART_MODE:-a\}"\s+fi/m,