From 0f555b8238cabe5ccc9fe0a42d0ed2dde03e00bc Mon Sep 17 00:00:00 2001 From: ashx-j Date: Fri, 30 Oct 2026 03:11:28 +0100 Subject: [PATCH 1/4] fix(provider-core): provider updates find npm on the provider's custom PATH Fixes #27132 Co-Authored-By: Claude Opus 5.5 --- .../src/provider/providerMaintenanceRunner.ts | 5 +++-- .../provider-core/src/server/maintenance.ts | 5 ++++- .../src/server/maintenanceResolver.test.ts | 10 ++++++---- .../src/server/maintenanceResolver.ts | 17 ++++++++++++----- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 9dddf23579d8a..eb1f15e8e539f 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -97,13 +97,14 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR }) { const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( function* () { + const env = input.env ? { env: input.env, extendEnv: true } : {}; // Windows batch shims require shell dispatch through resolveSpawnCommand. - const resolved = yield* resolveSpawnCommand(input.command, input.args); + const resolved = yield* resolveSpawnCommand(input.command, input.args, env); const child = yield* input.spawner .spawn( ChildProcess.make(resolved.command, resolved.args, { shell: resolved.shell, - ...(input.env ? { env: input.env, extendEnv: true } : {}), + ...env, }), ) .pipe( diff --git a/packages/provider-core/src/server/maintenance.ts b/packages/provider-core/src/server/maintenance.ts index 4e402717608b8..88a5592c2e699 100644 --- a/packages/provider-core/src/server/maintenance.ts +++ b/packages/provider-core/src/server/maintenance.ts @@ -22,6 +22,9 @@ export interface ProviderMaintenanceCommandAction { readonly executable: string; readonly args: ReadonlyArray; readonly lockKey: string; - /** Native updaters must use the instance's custom home (e.g. `CODEX_HOME`). */ + /** + * The provider's environment, so the updater finds the same tools on `PATH` + * and the same custom home (e.g. `CODEX_HOME`) the provider runs with. + */ readonly env?: NodeJS.ProcessEnv; } diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index 60522e0c29f48..87e02b83c76e6 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -266,7 +266,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ); it.effect.skipIf(!symlinksSupported)( - "pins npm updates to the global prefix that owns the package", + "pins npm updates to the global prefix that owns the package and the provider's PATH", () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-npm-capabilities"); @@ -276,12 +276,14 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { "@example", "package-tool", ]); + // A version manager's Node, which only the provider's settings put on PATH. + const providerEnv = { PATH: NodePath.join(tempDir, "bin") }; const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( packageToolUpdate, { binaryPath: link, - env: { PATH: "" }, + env: providerEnv, }, ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); @@ -300,6 +302,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { "@example/package-tool@latest", ], lockKey: `npm-global:${normalizeCommandPath(tempDir)}`, + env: providerEnv, }, }); }), @@ -668,9 +671,8 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( packageToolUpdate, - { binaryPath: link, env: { PATH: "" } }, + { binaryPath: link, env: { PATH: pnpmBinDir } }, ).pipe( - Effect.provideService(HostProcess.Environment, { PATH: pnpmBinDir }), Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, stdoutSpawner( diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 8a8e11e2ec0d7..7c629eee62dd1 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -391,15 +391,16 @@ const decodePnpmList = Schema.decodeUnknownOption( // Unlike npm's latest tag, pnpm outdated respects minimumReleaseAge and its excludes. const resolvePnpmGlobalLatestVersion = Effect.fn("resolvePnpmGlobalLatestVersion")(function* ( packageName: string, + env: NodeJS.ProcessEnv, ) { - // Match the updater's pnpm and server environment, rather than the instance's. - const pnpmPath = yield* resolveCommandPath("pnpm"); + // Match the updater's pnpm and environment. + const pnpmPath = yield* resolveCommandPath("pnpm", { env }); if (!pnpmPath) return null; // Silent reporting keeps project package-manager warnings out of the JSON. const outdated = yield* runProbe( pnpmPath, ["outdated", "-g", "--reporter=silent", "--format", "json", packageName], - {}, + env, // pnpm outdated exits 1 when updates are available. [0, 1], ); @@ -413,7 +414,7 @@ const resolvePnpmGlobalLatestVersion = Effect.fn("resolvePnpmGlobalLatestVersion const listed = yield* runProbe( pnpmPath, ["ls", "-g", "--reporter=silent", "--depth", "0", "--json", packageName], - {}, + env, ); const listedEntries = listed ? Option.getOrUndefined(decodePnpmList(listed)) : undefined; return nonEmptyString(listedEntries?.[0]?.dependencies?.[packageName]?.version); @@ -485,6 +486,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( updateExecutable: "vp", updateArgs: ["i", "-g", packageName], updateLockKey: "vite-plus-global", + env: context.env, }); } if (commandPaths.some(isBunGlobalCommandPath)) { @@ -494,6 +496,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( updateExecutable: "bun", updateArgs: ["i", "-g", `${packageName}@latest`], updateLockKey: "bun-global", + env: context.env, }); } if (commandPaths.some(isPnpmGlobalCommandPath)) { @@ -510,7 +513,8 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( updateExecutable: "pnpm", updateArgs: ["add", "-g", ...allowBuild, `${packageName}@latest`], updateLockKey: "pnpm-global", - lookupLatestVersion: resolvePnpmGlobalLatestVersion(packageName).pipe( + env: context.env, + lookupLatestVersion: resolvePnpmGlobalLatestVersion(packageName, context.env).pipe( Effect.provideContext(services), ), }); @@ -523,6 +527,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( updateExecutable: "yarn", updateArgs: ["global", "add", `${packageName}@latest`], updateLockKey: "yarn-global", + env: context.env, }); } if (yield* isVoltaPackageInstall(context, packageName)) { @@ -532,6 +537,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( updateExecutable: "volta", updateArgs: ["install", `${packageName}@latest`], updateLockKey: "volta", + env: context.env, }); } @@ -552,6 +558,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( `${packageName}@latest`, ], updateLockKey: `npm-global:${normalizeCommandPath(npmPrefix)}`, + env: context.env, }); } From 0f4cd3e47ee2cc520b9d1a630a3a547cbcfe4804 Mon Sep 17 00:00:00 2001 From: ashx-j Date: Fri, 30 Oct 2026 11:54:15 +0100 Subject: [PATCH 2/4] test(server): resolve the Windows npm shim from the provider's PATH Co-Authored-By: Claude Opus 5.5 --- .../providerMaintenanceRunner.test.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 75c592b7811a1..04d0453629767 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -618,7 +618,7 @@ describe("providerMaintenanceRunner", () => { ), ); - it.effect("resolves npm to a .cmd shim and routes through the shell on win32", () => { + it.effect("resolves npm to a .cmd shim from the provider's PATH on win32", () => { const captured: Array<{ readonly command: string; readonly args: ReadonlyArray; @@ -626,7 +626,20 @@ describe("providerMaintenanceRunner", () => { }> = []; return Effect.gen(function* () { const { registry } = yield* makeRegistry(baseProvider); - const runner = yield* makeTestRunner(registry); + const runner = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "npm", + updateArgs: ["install", "-g", "@openai/codex@latest"], + updateLockKey: "npm-global", + env: { PATH: "C:\\fake\\npm" }, + }), + ), + }); const result = yield* runner.updateProvider(CODEX_DRIVER); @@ -644,12 +657,15 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( Layer.succeed(HostProcess.Platform, "win32"), + // Only the provider's PATH has npm. Layer.succeed(HostProcess.Environment, { - PATH: "C:\\fake\\npm", + PATH: "C:\\Windows\\System32", PATHEXT: ".COM;.EXE;.BAT;.CMD", }), - Layer.succeed(SpawnExecutableResolution, (command) => - command === "npm" ? "C:\\fake\\npm\\npm.cmd" : undefined, + Layer.succeed(SpawnExecutableResolution, (command, _platform, env) => + command === "npm" && env.PATH === "C:\\fake\\npm" + ? "C:\\fake\\npm\\npm.cmd" + : undefined, ), layerLatestVersionHttpClient("0.0.0"), Layer.succeed( From ff27482688508404f42997bd8b618da3eb93e9b5 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 03:44:58 +0000 Subject: [PATCH 3/4] fix(provider-core): use the updater environment for npm version lookups --- .../server/src/provider/providerMaintenanceRunner.test.ts | 8 +++++++- packages/provider-core/src/server/maintenance.ts | 5 +---- .../provider-core/src/server/maintenanceResolver.test.ts | 2 +- packages/provider-core/src/server/maintenanceResolver.ts | 5 +++-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 2e648472889a8..24763a0b84be4 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -861,6 +861,7 @@ describe("providerMaintenanceRunner", () => { readonly command: string; readonly args: ReadonlyArray; readonly shell: boolean | string | undefined; + readonly env: NodeJS.ProcessEnv | undefined; }> = []; return Effect.gen(function* () { const { registry } = yield* makeRegistry(baseProvider, { @@ -892,6 +893,7 @@ describe("providerMaintenanceRunner", () => { ); assert.deepEqual(call.args, []); assert.strictEqual(call.shell, true); + assert.deepStrictEqual(call.env, { PATH: "C:\\fake\\npm" }); assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); }).pipe( Effect.provide( @@ -914,12 +916,16 @@ describe("providerMaintenanceRunner", () => { const childProcess = command as unknown as { readonly command: string; readonly args: ReadonlyArray; - readonly options: { readonly shell?: boolean | string | undefined }; + readonly options: { + readonly shell?: boolean | string | undefined; + readonly env?: NodeJS.ProcessEnv | undefined; + }; }; captured.push({ command: childProcess.command, args: childProcess.args, shell: childProcess.options.shell, + env: childProcess.options.env, }); return Effect.succeed(mockHandle({ stdout: "updated" })); }), diff --git a/packages/provider-core/src/server/maintenance.ts b/packages/provider-core/src/server/maintenance.ts index 88a5592c2e699..f6122c4afae62 100644 --- a/packages/provider-core/src/server/maintenance.ts +++ b/packages/provider-core/src/server/maintenance.ts @@ -22,9 +22,6 @@ export interface ProviderMaintenanceCommandAction { readonly executable: string; readonly args: ReadonlyArray; readonly lockKey: string; - /** - * The provider's environment, so the updater finds the same tools on `PATH` - * and the same custom home (e.g. `CODEX_HOME`) the provider runs with. - */ + /** The provider's environment, including its PATH and custom home. */ readonly env?: NodeJS.ProcessEnv; } diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index d3631b74210dd..a75e728e9e674 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -232,7 +232,7 @@ it.layer( let probes = 0; const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( fixture.install === "native" ? nativePackageToolUpdate : packageToolUpdate, - { binaryPath, env: { PATH: "" } }, + { binaryPath, env: { PATH: fixture.install === "npm" ? npmBinDir : "" } }, ).pipe( Effect.provideService(HostProcess.Environment, { PATH: npmBinDir }), Effect.provideService( diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index dab5d81460299..48c959569ea3d 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -730,7 +730,8 @@ const withConfiguredNpmVersionLookup = Effect.fn("withConfiguredNpmVersionLookup if (!packageName || capabilities.latestVersion !== undefined) { return capabilities; } - const npmPath = yield* resolveCommandPath("npm"); + const env = { ...(yield* HostProcess.Environment), ...capabilities.update?.env }; + const npmPath = yield* resolveCommandPath("npm", { env }); if (!npmPath) return capabilities; const services = yield* Effect.context(); const update = capabilities.update; @@ -748,7 +749,7 @@ const withConfiguredNpmVersionLookup = Effect.fn("withConfiguredNpmVersionLookup `${packageName}@latest`, "version", ], - {}, + env, ).pipe( Effect.map((stdout) => { const value = stdout ? Option.getOrNull(decodeNpmViewVersion(stdout.trim())) : null; From bb10dc65179e5aba44820d9735cf1adb7871e49e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 03:56:03 +0000 Subject: [PATCH 4/4] fix(provider-core): inherit PATH for partial pnpm environments --- .../provider-core/src/server/maintenanceResolver.test.ts | 8 ++++++-- packages/provider-core/src/server/maintenanceResolver.ts | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index a75e728e9e674..b5f465221cdc3 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -767,7 +767,8 @@ it.layer( it.effect.each([ { - case: "the eligible release", + case: "the eligible release with an inherited PATH", + inheritPath: true, outdated: { "@example/package-tool": { current: "1.0.0", latest: "1.1.0" } }, exitCode: 1, npmOutput: '"1.2.0"', @@ -822,8 +823,11 @@ it.layer( let npmProbes = 0; const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( packageToolUpdate, - { binaryPath: link, env: { PATH: pnpmBinDir } }, + { binaryPath: link, env: fixture.inheritPath ? {} : { PATH: pnpmBinDir } }, ).pipe( + Effect.provideService(HostProcess.Environment, { + PATH: fixture.inheritPath ? pnpmBinDir : "", + }), Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, stdoutSpawner( diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 48c959569ea3d..db68de21019dd 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -395,7 +395,9 @@ const resolvePnpmGlobalLatestVersion = Effect.fn("resolvePnpmGlobalLatestVersion env: NodeJS.ProcessEnv, ) { // Match the updater's pnpm and environment. - const pnpmPath = yield* resolveCommandPath("pnpm", { env }); + const pnpmPath = yield* resolveCommandPath("pnpm", { + env: { ...(yield* HostProcess.Environment), ...env }, + }); if (!pnpmPath) return null; // Silent reporting keeps project package-manager warnings out of the JSON. const outdated = yield* runProbe(