fix: bypass npm freshness for managed installs (#83761)

* fix: bypass npm freshness for managed installs

* test: tolerate npm config json differences

* test: align npm freshness bypass expectation

* fix: resolve npm config path expansions

* test: tolerate npm zero config encoding

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Josh Lehman
2026-05-19 00:31:02 +02:00
committed by GitHub
co-authored by Peter Steinberger
parent d761b98adc
commit 85a3d5312f
14 changed files with 441 additions and 74 deletions
+1
View File
@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Memory/search: scan the JS-side fallback vector path (used when the sqlite-vec index is unavailable or has a mismatched dimension) in bounded rowid batches and yield to the event loop between batches so large chunk tables can no longer pin the Node.js main thread for multi-second windows. Also keeps the SQL prepared statement rooted in a local so node:sqlite cannot finalize it mid-scan under heap pressure. Fixes #81172. Thanks @dev23xyz-oss.
- CLI/update: bypass npm freshness filters consistently during managed package and plugin installs so freshly published release plugins remain installable. Thanks @jalehman.
- Agents/subagents: keep collect-mode announce queues batching unresolved-origin items with compatible same-route messages and resume collection after a true cross-channel drain when a later compatible batch remains. Fixes #83577.
- Providers/Anthropic: preserve native image input for current Claude model rows when stale local catalog data marks them text-only. (#83756) Thanks @TurboTheTurtle.
- Control UI: render live tool progress from session-scoped `session.tool` Gateway events so externally started runs show their tool cards in the active session. (#83734) Thanks @TurboTheTurtle.
+1 -1
View File
@@ -259,7 +259,7 @@ export async function installPackageDir(params: {
{
timeoutMs: Math.max(params.timeoutMs, 300_000),
cwd: stageDir,
env: createSafeNpmInstallEnv(process.env),
env: createSafeNpmInstallEnv(process.env, { npmConfigCwd: stageDir }),
},
);
} finally {
+3 -3
View File
@@ -210,13 +210,13 @@ describe("packNpmSpecToArchive", () => {
timeoutMs: 300_000,
env: {
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
NPM_CONFIG_BEFORE: "",
NPM_CONFIG_IGNORE_SCRIPTS: "true",
NPM_CONFIG_BEFORE: "",
NPM_CONFIG_MIN_RELEASE_AGE: "",
"NPM_CONFIG_MIN-RELEASE-AGE": "",
npm_config_before: "",
"npm_config_min-release-age": "0",
npm_config_min_release_age: "",
"npm_config_min-release-age": "",
npm_config_min_release_age: "0",
},
},
);
+6 -4
View File
@@ -5,7 +5,7 @@ import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import { resolveArchiveKind } from "./archive.js";
import { pathExists } from "./fs-safe.js";
import { applyNpmFreshnessBypassEnv } from "./npm-install-env.js";
import { applyNpmFreshnessBypassEnv, type NpmProjectInstallEnvOptions } from "./npm-install-env.js";
import { withTempWorkspace } from "./private-temp-workspace.js";
export type NpmSpecResolution = {
@@ -37,12 +37,14 @@ export function buildNpmResolutionFields(resolution?: NpmSpecResolution): NpmRes
};
}
export function createNpmMetadataEnv(): NodeJS.ProcessEnv {
export function createNpmMetadataEnv(
scope: Pick<NpmProjectInstallEnvOptions, "npmConfigCwd"> = {},
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
NPM_CONFIG_IGNORE_SCRIPTS: "true",
};
applyNpmFreshnessBypassEnv(env);
applyNpmFreshnessBypassEnv(env, new Date(), scope);
return env;
}
@@ -286,7 +288,7 @@ export async function packNpmSpecToArchive(params: {
{
timeoutMs: Math.max(params.timeoutMs, 300_000),
cwd: params.cwd,
env: createNpmMetadataEnv(),
env: createNpmMetadataEnv({ npmConfigCwd: params.cwd }),
},
);
if (res.code !== 0) {
+276 -31
View File
@@ -1,19 +1,42 @@
import { execFileSync } from "node:child_process";
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withMockedPlatform, withRestoredMocks } from "../test-utils/vitest-spies.js";
import { createNpmProjectInstallEnv } from "./npm-install-env.js";
import { createNpmFreshnessBypassArgs, createNpmProjectInstallEnv } from "./npm-install-env.js";
const EXPECTED_MIN_FRESHNESS_ENV = {
const FROZEN_NOW = new Date("2026-05-18T19:55:00.000Z");
const EXPECTED_FRESHNESS_ENV = {
NPM_CONFIG_BEFORE: "",
NPM_CONFIG_MIN_RELEASE_AGE: "",
"NPM_CONFIG_MIN-RELEASE-AGE": "",
npm_config_before: "",
"npm_config_min-release-age": "0",
npm_config_min_release_age: "",
"npm_config_min-release-age": "",
npm_config_min_release_age: "0",
};
function readNpmConfigList(env: NodeJS.ProcessEnv): Record<string, unknown> {
const raw = execFileSync("npm", ["config", "list", "--json"], {
encoding: "utf-8",
env: {
...process.env,
...env,
},
stdio: ["ignore", "pipe", "pipe"],
timeout: 5_000,
});
return JSON.parse(raw) as Record<string, unknown>;
}
function expectUnsetNpmJsonConfig(value: unknown): void {
expect(value == null).toBe(true);
}
function expectZeroNpmJsonConfig(value: unknown): void {
expect(value === 0 || value === "0").toBe(true);
}
describe("npm project install env", () => {
it("uses an absolute POSIX script shell for npm lifecycle scripts", () => {
withMockedPlatform("linux", () => {
@@ -22,11 +45,15 @@ describe("npm project install env", () => {
.mockImplementation((candidate) => candidate === "/bin/sh");
withRestoredMocks([existsSyncSpy], () => {
expect(
createNpmProjectInstallEnv({
PATH: "/tmp/openclaw-npm-global/bin",
}),
createNpmProjectInstallEnv(
{
PATH: "/tmp/openclaw-npm-global/bin",
},
{},
FROZEN_NOW,
),
).toEqual({
...EXPECTED_MIN_FRESHNESS_ENV,
...EXPECTED_FRESHNESS_ENV,
NPM_CONFIG_SCRIPT_SHELL: "/bin/sh",
PATH: "/tmp/openclaw-npm-global/bin",
npm_config_dry_run: "false",
@@ -46,11 +73,15 @@ describe("npm project install env", () => {
it("preserves explicit npm script shell config", () => {
withMockedPlatform("linux", () => {
expect(
createNpmProjectInstallEnv({
NPM_CONFIG_SCRIPT_SHELL: "/custom/sh",
}),
createNpmProjectInstallEnv(
{
NPM_CONFIG_SCRIPT_SHELL: "/custom/sh",
},
{},
FROZEN_NOW,
),
).toEqual({
...EXPECTED_MIN_FRESHNESS_ENV,
...EXPECTED_FRESHNESS_ENV,
NPM_CONFIG_SCRIPT_SHELL: "/custom/sh",
npm_config_dry_run: "false",
npm_config_fetch_retries: "5",
@@ -63,11 +94,15 @@ describe("npm project install env", () => {
npm_config_save: "false",
});
expect(
createNpmProjectInstallEnv({
npm_config_script_shell: "/custom/lower-sh",
}),
createNpmProjectInstallEnv(
{
npm_config_script_shell: "/custom/lower-sh",
},
{},
FROZEN_NOW,
),
).toEqual({
...EXPECTED_MIN_FRESHNESS_ENV,
...EXPECTED_FRESHNESS_ENV,
npm_config_dry_run: "false",
npm_config_fetch_retries: "5",
npm_config_fetch_retry_maxtimeout: "120000",
@@ -83,30 +118,35 @@ describe("npm project install env", () => {
});
it("bypasses npm release-age filters for OpenClaw-managed installs", () => {
const env = createNpmProjectInstallEnv({
NPM_CONFIG_BEFORE: "2026-01-01T00:00:00.000Z",
NPM_CONFIG_MIN_RELEASE_AGE: "7",
"npm_config_min-release-age": "7",
npm_config_before: "2026-01-01T00:00:00.000Z",
npm_config_min_release_age: "7",
});
const env = createNpmProjectInstallEnv(
{
NPM_CONFIG_BEFORE: "2026-01-01T00:00:00.000Z",
NPM_CONFIG_MIN_RELEASE_AGE: "7",
"npm_config_min-release-age": "7",
npm_config_before: "2026-01-01T00:00:00.000Z",
npm_config_min_release_age: "7",
},
{},
FROZEN_NOW,
);
expect(env.NPM_CONFIG_BEFORE).toBe("");
expect(env.npm_config_before).toBe("");
expect(env.NPM_CONFIG_MIN_RELEASE_AGE).toBe("");
expect(env["npm_config_min-release-age"]).toBe("0");
expect(env.npm_config_min_release_age).toBe("");
expect(env["npm_config_min-release-age"]).toBe("");
expect(env.npm_config_min_release_age).toBe("0");
});
it("does not leak parent npm freshness env into explicit child envs", () => {
const previousBefore = process.env.NPM_CONFIG_BEFORE;
process.env.NPM_CONFIG_BEFORE = "2026-01-01T00:00:00.000Z";
try {
const env = createNpmProjectInstallEnv({});
const env = createNpmProjectInstallEnv({}, {}, FROZEN_NOW);
expect(env.NPM_CONFIG_BEFORE).toBe("");
expect(env.npm_config_before).toBe("");
expect(env["npm_config_min-release-age"]).toBe("0");
expect(env["npm_config_min-release-age"]).toBe("");
expect(env.npm_config_min_release_age).toBe("0");
} finally {
if (previousBefore == null) {
delete process.env.NPM_CONFIG_BEFORE;
@@ -121,13 +161,218 @@ describe("npm project install env", () => {
try {
const npmrc = path.join(dir, "npmrc");
fsSync.writeFileSync(npmrc, "before=2026-01-01T00:00:00.000Z\n", "utf-8");
const env = createNpmProjectInstallEnv({
NPM_CONFIG_USERCONFIG: npmrc,
});
const env = createNpmProjectInstallEnv(
{
NPM_CONFIG_USERCONFIG: npmrc,
},
{},
FROZEN_NOW,
);
expect(env["npm_config_min-release-age"]).toBe("");
expect(env.npm_config_before).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(env.npm_config_min_release_age).toBe("");
expect(env.npm_config_before).toBe(FROZEN_NOW.toISOString());
expect(env.npm_config_before).not.toBe("2026-01-01T00:00:00.000Z");
const envWithParentAge = createNpmProjectInstallEnv(
{
NPM_CONFIG_USERCONFIG: npmrc,
NPM_CONFIG_MIN_RELEASE_AGE: "7",
},
{},
FROZEN_NOW,
);
expect(envWithParentAge.npm_config_min_release_age).toBe("");
expect(envWithParentAge.npm_config_before).toBe(FROZEN_NOW.toISOString());
} finally {
fsSync.rmSync(dir, { recursive: true, force: true });
}
});
it("uses release-age args by default", () => {
expect(createNpmFreshnessBypassArgs({}, FROZEN_NOW)).toEqual(["--min-release-age=0"]);
});
it("uses before args for stale npm before policies", () => {
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-npmrc-"));
try {
const npmrc = path.join(dir, "npmrc");
fsSync.writeFileSync(npmrc, "before=2026-01-01T00:00:00.000Z\n", "utf-8");
expect(
createNpmFreshnessBypassArgs(
{
NPM_CONFIG_USERCONFIG: npmrc,
},
FROZEN_NOW,
),
).toEqual([`--before=${FROZEN_NOW.toISOString()}`]);
} finally {
fsSync.rmSync(dir, { recursive: true, force: true });
}
});
it("uses before args for expanded npm userconfig paths", () => {
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-home-npmrc-"));
try {
fsSync.writeFileSync(path.join(dir, ".npmrc"), "before=2026-01-01T00:00:00.000Z\n", "utf-8");
expect(
createNpmFreshnessBypassArgs(
{
HOME: dir,
NPM_CONFIG_USERCONFIG: "~/.npmrc",
},
FROZEN_NOW,
),
).toEqual([`--before=${FROZEN_NOW.toISOString()}`]);
expect(
createNpmFreshnessBypassArgs(
{
HOME: dir,
NPM_CONFIG_USERCONFIG: "${HOME}/.npmrc",
},
FROZEN_NOW,
),
).toEqual([`--before=${FROZEN_NOW.toISOString()}`]);
} finally {
fsSync.rmSync(dir, { recursive: true, force: true });
}
});
it("uses before args for npm default globalconfig before policies", () => {
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-npm-prefix-"));
try {
const npmrcDir = path.join(dir, "etc");
fsSync.mkdirSync(npmrcDir, { recursive: true });
fsSync.writeFileSync(
path.join(npmrcDir, "npmrc"),
"before=2026-01-01T00:00:00.000Z\n",
"utf-8",
);
expect(
createNpmFreshnessBypassArgs(
{
NPM_CONFIG_PREFIX: dir,
},
FROZEN_NOW,
),
).toEqual([`--before=${FROZEN_NOW.toISOString()}`]);
} finally {
fsSync.rmSync(dir, { recursive: true, force: true });
}
});
it("uses before args for command project npmrc before policies", () => {
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-project-npmrc-"));
try {
fsSync.writeFileSync(path.join(dir, ".npmrc"), "before=2026-01-01T00:00:00.000Z\n", "utf-8");
expect(createNpmFreshnessBypassArgs({}, FROZEN_NOW, { npmConfigCwd: dir })).toEqual([
`--before=${FROZEN_NOW.toISOString()}`,
]);
const env = createNpmProjectInstallEnv({}, { npmConfigCwd: dir }, FROZEN_NOW);
expect(env.npm_config_min_release_age).toBe("");
expect(env.npm_config_before).toBe(FROZEN_NOW.toISOString());
} finally {
fsSync.rmSync(dir, { recursive: true, force: true });
}
});
it("uses before args for the current project npmrc by default", () => {
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-current-npmrc-"));
try {
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([
`--before=${FROZEN_NOW.toISOString()}`,
]);
});
} finally {
fsSync.rmSync(dir, { recursive: true, force: true });
}
});
it("uses before args for scoped npm prefix before policies", () => {
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-prefix-npmrc-"));
try {
const npmrcDir = path.join(dir, "etc");
fsSync.mkdirSync(npmrcDir, { recursive: true });
fsSync.writeFileSync(
path.join(npmrcDir, "npmrc"),
"before=2026-01-01T00:00:00.000Z\n",
"utf-8",
);
expect(createNpmFreshnessBypassArgs({}, FROZEN_NOW, { npmConfigPrefix: dir })).toEqual([
`--before=${FROZEN_NOW.toISOString()}`,
]);
} finally {
fsSync.rmSync(dir, { recursive: true, force: true });
}
});
it("overrides stale npmrc before config without emitting release-age config", () => {
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-npmrc-"));
try {
const npmrc = path.join(dir, "npmrc");
fsSync.writeFileSync(npmrc, "before=2026-01-01T00:00:00.000Z\n", "utf-8");
const env = createNpmProjectInstallEnv(
{
NPM_CONFIG_USERCONFIG: npmrc,
},
{},
FROZEN_NOW,
);
const npmConfig = readNpmConfigList(env);
expect(npmConfig.before).toBe(FROZEN_NOW.toISOString());
expectUnsetNpmJsonConfig(npmConfig["min-release-age"]);
} finally {
fsSync.rmSync(dir, { recursive: true, force: true });
}
});
it("uses release-age args for npmrc release-age policies", () => {
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-npmrc-"));
try {
const npmrc = path.join(dir, "npmrc");
fsSync.writeFileSync(npmrc, "min-release-age=7\n", "utf-8");
expect(
createNpmFreshnessBypassArgs(
{
NPM_CONFIG_USERCONFIG: npmrc,
},
FROZEN_NOW,
),
).toEqual(["--min-release-age=0"]);
} finally {
fsSync.rmSync(dir, { recursive: true, force: true });
}
});
it("overrides npmrc release-age config without emitting before config", () => {
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-npmrc-"));
try {
const npmrc = path.join(dir, "npmrc");
fsSync.writeFileSync(npmrc, "min-release-age=7\n", "utf-8");
const env = createNpmProjectInstallEnv(
{
NPM_CONFIG_USERCONFIG: npmrc,
},
{},
FROZEN_NOW,
);
expect(env.npm_config_before).toBe("");
expect(env.npm_config_min_release_age).toBe("0");
const npmConfig = readNpmConfigList(env);
expect(npmConfig.before == null || typeof npmConfig.before === "string").toBe(true);
expectZeroNpmJsonConfig(npmConfig["min-release-age"]);
} finally {
fsSync.rmSync(dir, { recursive: true, force: true });
}
+120 -23
View File
@@ -1,15 +1,17 @@
import { execFileSync } from "node:child_process";
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
export type NpmProjectInstallEnvOptions = {
cacheDir?: string;
npmConfigCwd?: string;
npmConfigPrefix?: string | null;
};
const NPM_CONFIG_SCRIPT_SHELL_KEYS = ["NPM_CONFIG_SCRIPT_SHELL", "npm_config_script_shell"];
const NPM_CONFIG_KEYS_TO_RESET = new Set([
"npm_config_before",
"npm_config_cache",
"npm_config_dry_run",
"npm_config_global",
@@ -21,8 +23,6 @@ const NPM_CONFIG_KEYS_TO_RESET = new Set([
"npm_config_strict_peer_deps",
"npm_config_workspace",
"npm_config_workspaces",
"npm_config_min_release_age",
"npm_config_min-release-age",
]);
const NPM_FRESHNESS_BYPASS_KEYS = [
@@ -34,11 +34,54 @@ const NPM_FRESHNESS_BYPASS_KEYS = [
"npm_config_min-release-age",
] as const;
const NPM_CONFIG_PROBE_PARENT_ENV_KEYS = ["PATH", "Path", "PATHEXT", "SystemRoot", "ComSpec"];
type NpmFreshnessBypassMode = "before" | "min-release-age";
function createNpmConfigProbeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
type NpmFreshnessConfigScope = {
npmConfigCwd?: string;
npmConfigPrefix?: string | null;
};
const NPM_CONFIG_PATH_PROBE_PARENT_ENV_KEYS = ["PATH", "Path", "PATHEXT", "SystemRoot", "ComSpec"];
function resolveEnvPath(env: NodeJS.ProcessEnv, upperKey: string, lowerKey: string): string | null {
const raw = env[upperKey]?.trim() || env[lowerKey]?.trim();
return raw ? resolveNpmConfigPath(raw, env) : null;
}
function resolveHomeNpmrc(env: NodeJS.ProcessEnv): string {
const home = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir();
return path.join(home, ".npmrc");
}
function replaceNpmEnvRefs(value: string, env: NodeJS.ProcessEnv): string {
return value.replace(
/(?<!\\)(\\*)\$\{([^${}?]+)(\?)?\}/gu,
(original, escapes, name, modifier) => {
const fallback = modifier === "?" ? "" : `\${${name}}`;
const resolved = env[name] !== undefined ? env[name] : fallback;
if (escapes.length % 2) {
return original.slice((escapes.length + 1) / 2);
}
return `${escapes.slice(escapes.length / 2)}${resolved}`;
},
);
}
function resolveNpmConfigPath(rawPath: string, env: NodeJS.ProcessEnv): string {
const expanded = replaceNpmEnvRefs(rawPath, env);
const home = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir();
const homePattern = process.platform === "win32" ? /^~(\/|\\)/u : /^~\//u;
return homePattern.test(expanded) && home
? path.resolve(home, expanded.slice(2))
: path.resolve(expanded);
}
function createNpmConfigPathProbeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const probeEnv = { ...env };
for (const key of NPM_CONFIG_PROBE_PARENT_ENV_KEYS) {
for (const key of NPM_FRESHNESS_BYPASS_KEYS) {
delete probeEnv[key];
}
for (const key of NPM_CONFIG_PATH_PROBE_PARENT_ENV_KEYS) {
if (probeEnv[key] == null && process.env[key] != null) {
probeEnv[key] = process.env[key];
}
@@ -46,56 +89,110 @@ function createNpmConfigProbeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return probeEnv;
}
function readNpmConfigValue(key: string, env: NodeJS.ProcessEnv): string | null {
function readNpmGlobalConfigPath(
env: NodeJS.ProcessEnv,
scope: NpmFreshnessConfigScope,
): string | null {
try {
return execFileSync("npm", ["config", "get", key], {
const raw = execFileSync("npm", ["config", "get", "globalconfig"], {
encoding: "utf-8",
env: createNpmConfigProbeEnv(env),
env: {
...createNpmConfigPathProbeEnv(env),
...(scope.npmConfigPrefix ? { npm_config_prefix: scope.npmConfigPrefix } : {}),
},
stdio: ["ignore", "pipe", "ignore"],
timeout: 2_000,
}).trim();
return raw && raw !== "null" && raw !== "undefined" ? raw : null;
} catch {
return null;
}
}
function isNullNpmConfigValue(value: string | null): boolean {
return !value || value === "null" || value === "undefined";
function resolveScopedProjectNpmrc(scope: NpmFreshnessConfigScope): string | null {
const cwd = scope.npmConfigCwd?.trim() || process.cwd();
return cwd ? path.join(cwd, ".npmrc") : null;
}
function hasExplicitNpmBeforeConfig(env: NodeJS.ProcessEnv): boolean {
const minReleaseAge = readNpmConfigValue("min-release-age", env);
if (!isNullNpmConfigValue(minReleaseAge)) {
function resolveScopedGlobalNpmrc(scope: NpmFreshnessConfigScope): string | null {
const prefix = scope.npmConfigPrefix?.trim();
return prefix ? path.join(prefix, "etc", "npmrc") : null;
}
function resolveNpmConfigFiles(
env: NodeJS.ProcessEnv,
scope: NpmFreshnessConfigScope = {},
): string[] {
const files = [
resolveScopedProjectNpmrc(scope),
resolveEnvPath(env, "NPM_CONFIG_USERCONFIG", "npm_config_userconfig") ?? resolveHomeNpmrc(env),
resolveEnvPath(env, "NPM_CONFIG_GLOBALCONFIG", "npm_config_globalconfig"),
resolveScopedGlobalNpmrc(scope),
readNpmGlobalConfigPath(env, scope),
];
return [...new Set(files.filter((file): file is string => Boolean(file)))];
}
function hasNpmrcConfigKey(filePath: string, key: string): boolean {
try {
const raw = fsSync.readFileSync(filePath, "utf-8");
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
const pattern = new RegExp(`^\\s*${escapedKey}\\s*=`, "imu");
return pattern.test(raw);
} catch {
return false;
}
return !isNullNpmConfigValue(readNpmConfigValue("before", env));
}
function hasRawNpmConfigKey(
env: NodeJS.ProcessEnv,
key: "before" | "min-release-age",
scope: NpmFreshnessConfigScope,
): boolean {
return resolveNpmConfigFiles(env, scope).some((file) => hasNpmrcConfigKey(file, key));
}
function resolveNpmFreshnessBypassMode(
env: NodeJS.ProcessEnv,
scope: NpmFreshnessConfigScope,
): NpmFreshnessBypassMode {
if (hasRawNpmConfigKey(env, "min-release-age", scope)) {
return "min-release-age";
}
return hasRawNpmConfigKey(env, "before", scope) ? "before" : "min-release-age";
}
export function createNpmFreshnessBypassArgs(
env: NodeJS.ProcessEnv = process.env,
now = new Date(),
scope: NpmFreshnessConfigScope = {},
): string[] {
if (hasExplicitNpmBeforeConfig(env)) {
return [`--before=${now.toISOString()}`];
if (resolveNpmFreshnessBypassMode(env, scope) === "min-release-age") {
return ["--min-release-age=0"];
}
return ["--min-release-age=0"];
return [`--before=${now.toISOString()}`];
}
export function applyNpmFreshnessBypassEnv(env: NodeJS.ProcessEnv): void {
export function applyNpmFreshnessBypassEnv(
env: NodeJS.ProcessEnv,
now = new Date(),
scope: NpmFreshnessConfigScope = {},
): void {
const [arg] = createNpmFreshnessBypassArgs(env, now, scope);
for (const key of NPM_FRESHNESS_BYPASS_KEYS) {
env[key] = "";
}
const [arg] = createNpmFreshnessBypassArgs(env);
if (arg?.startsWith("--before=")) {
env.npm_config_before = arg.slice("--before=".length);
return;
} else if (arg === "--min-release-age=0") {
env.npm_config_min_release_age = "0";
}
env["npm_config_min-release-age"] = "0";
}
export function createNpmProjectInstallEnv(
env: NodeJS.ProcessEnv,
options: NpmProjectInstallEnvOptions = {},
now = new Date(),
): NodeJS.ProcessEnv {
const nextEnv = { ...env };
for (const key of Object.keys(nextEnv)) {
@@ -116,7 +213,7 @@ export function createNpmProjectInstallEnv(
npm_config_save: "false",
...(options.cacheDir ? { npm_config_cache: options.cacheDir } : {}),
};
applyNpmFreshnessBypassEnv(installEnv);
applyNpmFreshnessBypassEnv(installEnv, now, options);
applyPosixNpmScriptShellEnv(installEnv);
return installEnv;
}
+3
View File
@@ -599,6 +599,7 @@ async function collectNpmResolvedManagedNpmRootPeerDependencyPins(params: {
timeoutMs: Math.max(params.timeoutMs ?? 300_000, 300_000),
env: createSafeNpmInstallEnv(process.env, {
legacyPeerDeps: false,
npmConfigCwd: tempRoot,
packageLock: true,
quiet: true,
}),
@@ -614,6 +615,7 @@ async function collectNpmResolvedManagedNpmRootPeerDependencyPins(params: {
...npmPlanOptions,
env: createSafeNpmInstallEnv(process.env, {
legacyPeerDeps: true,
npmConfigCwd: tempRoot,
packageLock: true,
quiet: true,
}),
@@ -815,6 +817,7 @@ export async function repairManagedNpmRootOpenClawPeer(params: {
timeoutMs: Math.max(params.timeoutMs ?? 300_000, 300_000),
env: createSafeNpmInstallEnv(process.env, {
legacyPeerDeps: true,
npmConfigCwd: params.npmRoot,
packageLock: true,
quiet: true,
}),
+3 -2
View File
@@ -76,8 +76,9 @@ describe("safe npm install helpers", () => {
expect(env.npm_config_yes).toBe("true");
expect(env.npm_config_include_workspace_root).toBeUndefined();
expect(env.npm_config_workspace).toBeUndefined();
expect(env["npm_config_min-release-age"]).toBe("0");
expect(env.npm_config_min_release_age).toBe("");
expect(env["npm_config_min-release-age"]).toBe("");
expect(env.npm_config_min_release_age).toBe("0");
expect(env.npm_config_before).toBe("");
});
it("does not inherit host legacy peer dependency mode by default", () => {
+2 -1
View File
@@ -148,7 +148,8 @@ describe("update global helpers", () => {
expect(defaultEnv?.COREPACK_ENABLE_DOWNLOAD_PROMPT).toBe("0");
expect(defaultEnv?.NPM_CONFIG_BEFORE).toBe("");
expect(defaultEnv?.npm_config_before).toBe("");
expect(defaultEnv?.["npm_config_min-release-age"]).toBe("0");
expect(defaultEnv?.["npm_config_min-release-age"]).toBe("");
expect(defaultEnv?.npm_config_min_release_age).toBe("0");
const explicitEnv = await createGlobalInstallEnv({
COREPACK_ENABLE_DOWNLOAD_PROMPT: "1",
+6 -2
View File
@@ -793,7 +793,9 @@ export function globalInstallArgs(
...(installPrefix ? ["--prefix", installPrefix] : []),
spec,
...NPM_GLOBAL_INSTALL_QUIET_FLAGS,
...createNpmFreshnessBypassArgs(),
...createNpmFreshnessBypassArgs(process.env, new Date(), {
npmConfigPrefix: installPrefix,
}),
];
}
@@ -815,7 +817,9 @@ export function globalInstallFallbackArgs(
spec,
"--omit=optional",
...NPM_GLOBAL_INSTALL_QUIET_FLAGS,
...createNpmFreshnessBypassArgs(),
...createNpmFreshnessBypassArgs(process.env, new Date(), {
npmConfigPrefix: installPrefix,
}),
];
}
+10 -6
View File
@@ -186,7 +186,7 @@ describe("runGatewayUpdate", () => {
argv: string[],
options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number },
) => {
const key = argv.join(" ");
const key = normalizeNpmFreshnessArgs(argv).join(" ");
calls.push(key);
const override = await params.onCommand?.(key, options);
if (override) {
@@ -286,6 +286,10 @@ describe("runGatewayUpdate", () => {
return { nodeModules, pkgRoot };
}
const npmFreshnessArg = "--min-release-age=0";
const normalizeNpmFreshnessArgs = (argv: string[]) =>
argv.map((arg) => (/^--before=\d{4}-\d{2}-\d{2}T/u.test(arg) ? npmFreshnessArg : arg));
const npmGlobalInstallCommand = (spec: string, extraArgs: string[] = []) =>
[
"npm",
@@ -296,7 +300,7 @@ describe("runGatewayUpdate", () => {
"--no-fund",
"--no-audit",
"--loglevel=error",
"--min-release-age=0",
npmFreshnessArg,
].join(" ");
function createGlobalNpmUpdateRunner(params: {
@@ -309,7 +313,7 @@ describe("runGatewayUpdate", () => {
const omitOptionalInstallKey = npmGlobalInstallCommand("openclaw@latest", ["--omit=optional"]);
return async (argv: string[]): Promise<CommandResult> => {
const key = argv.join(" ");
const key = normalizeNpmFreshnessArgs(argv).join(" ");
if (key === `git -C ${params.pkgRoot} rev-parse --show-toplevel`) {
return { stdout: "", stderr: "not a git repository", code: 128 };
}
@@ -1660,7 +1664,7 @@ describe("runGatewayUpdate", () => {
}) => {
const calls: string[] = [];
const runCommand = async (argv: string[], options?: { env?: NodeJS.ProcessEnv }) => {
const key = argv.join(" ");
const key = normalizeNpmFreshnessArgs(argv).join(" ");
calls.push(key);
if (key === `git -C ${params.pkgRoot} rev-parse --show-toplevel`) {
if (params.gitRootMode === "missing") {
@@ -1687,10 +1691,10 @@ describe("runGatewayUpdate", () => {
const prefixIndex = argv.indexOf("--prefix");
const installPrefix = prefixIndex >= 0 ? argv[prefixIndex + 1] : undefined;
if (installPrefix) {
const normalizedInstallCommand = [
const normalizedInstallCommand = normalizeNpmFreshnessArgs([
...argv.slice(0, prefixIndex),
...argv.slice(prefixIndex + 2),
].join(" ");
]).join(" ");
if (normalizedInstallCommand === params.installCommand) {
const packageRoot =
process.platform === "win32"
+5 -1
View File
@@ -324,7 +324,11 @@ export async function installPluginFromGitSpec(
{
cwd: repoDir,
timeoutMs: Math.max(params.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS, 300_000),
env: createSafeNpmInstallEnv(process.env, { packageLock: true, quiet: true }),
env: createSafeNpmInstallEnv(process.env, {
npmConfigCwd: repoDir,
packageLock: true,
quiet: true,
}),
},
);
if (install.code !== 0) {
+3
View File
@@ -355,6 +355,7 @@ async function rollbackManagedNpmPluginInstall(params: {
timeoutMs: Math.max(params.timeoutMs, 300_000),
env: createSafeNpmInstallEnv(process.env, {
legacyPeerDeps: true,
npmConfigCwd: params.npmRoot,
packageLock: true,
quiet: true,
}),
@@ -415,6 +416,7 @@ async function rollbackManagedNpmPluginInstall(params: {
timeoutMs: Math.max(params.timeoutMs, 300_000),
env: createSafeNpmInstallEnv(process.env, {
legacyPeerDeps: true,
npmConfigCwd: params.npmRoot,
packageLock: true,
quiet: true,
}),
@@ -697,6 +699,7 @@ async function installPluginFromManagedNpmRoot(
timeoutMs: Math.max(timeoutMs, 300_000),
env: createSafeNpmInstallEnv(process.env, {
legacyPeerDeps: true,
npmConfigCwd: npmRoot,
packageLock: true,
quiet: true,
}),
+2
View File
@@ -622,6 +622,7 @@ export async function applyPluginUninstallDirectoryRemoval(
timeoutMs: 300_000,
env: createSafeNpmInstallEnv(process.env, {
legacyPeerDeps: true,
npmConfigCwd: removal.cleanup.npmRoot,
packageLock: true,
quiet: true,
}),
@@ -660,6 +661,7 @@ export async function applyPluginUninstallDirectoryRemoval(
timeoutMs: 300_000,
env: createSafeNpmInstallEnv(process.env, {
legacyPeerDeps: true,
npmConfigCwd: removal.cleanup.npmRoot,
packageLock: true,
quiet: true,
}),