diff --git a/src/main/ipc/network-settings-sanitization.ts b/src/main/ipc/network-settings-sanitization.ts new file mode 100644 index 00000000000..ed2d4a1db08 --- /dev/null +++ b/src/main/ipc/network-settings-sanitization.ts @@ -0,0 +1,20 @@ +import type { GlobalSettings } from '../../shared/global-settings-types' +import { normalizeNodeDistMirrorUrl } from '../../shared/node-dist-mirror' +import { normalizeProxyBypassRules, normalizeProxyUrl } from '../../shared/network-proxy' + +/** Network fields as they may be stored: an invalid URL is saved as empty, never as typed. */ +export function sanitizeNetworkSettings( + args: Partial, + sanitized: Partial +): void { + if ('httpProxyUrl' in args) { + const proxyUrl = normalizeProxyUrl(args.httpProxyUrl) + sanitized.httpProxyUrl = proxyUrl.ok ? proxyUrl.value : '' + } + if ('httpProxyBypassRules' in args) { + sanitized.httpProxyBypassRules = normalizeProxyBypassRules(args.httpProxyBypassRules) + } + if ('nodeDistMirrorUrl' in args) { + sanitized.nodeDistMirrorUrl = normalizeNodeDistMirrorUrl(args.nodeDistMirrorUrl).value + } +} diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 65b7364bcab..0c80d82e0a3 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -18,7 +18,7 @@ import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetr import { applyElectronProxySettings } from '../network/proxy-settings' import { applyBrowserSessionProxies } from '../browser/browser-session-proxy' import { browserSessionRegistry } from '../browser/browser-session-registry' -import { normalizeProxyBypassRules, normalizeProxyUrl } from '../../shared/network-proxy' +import { sanitizeNetworkSettings } from './network-settings-sanitization' import { normalizeAppIconId } from '../../shared/app-icon' import { normalizeUiLanguage } from '../../shared/ui-language' import { applyAppIcon } from '../app-icon' @@ -155,13 +155,7 @@ export function registerSettingsHandlers( args.floatingTerminalCwd ) } - if ('httpProxyUrl' in args) { - const proxyUrl = normalizeProxyUrl(args.httpProxyUrl) - sanitizedArgs.httpProxyUrl = proxyUrl.ok ? proxyUrl.value : '' - } - if ('httpProxyBypassRules' in args) { - sanitizedArgs.httpProxyBypassRules = normalizeProxyBypassRules(args.httpProxyBypassRules) - } + sanitizeNetworkSettings(args, sanitizedArgs) if ('appIcon' in args) { sanitizedArgs.appIcon = normalizeAppIconId(args.appIcon) } diff --git a/src/main/managed-server/install/orcad-remote-node-runtime-host-fallback.test.ts b/src/main/managed-server/install/orcad-remote-node-runtime-host-fallback.test.ts index 0f13e771e8c..efd8b6578a7 100644 --- a/src/main/managed-server/install/orcad-remote-node-runtime-host-fallback.test.ts +++ b/src/main/managed-server/install/orcad-remote-node-runtime-host-fallback.test.ts @@ -13,6 +13,7 @@ import { execCommand } from '../../ssh/remote-exec/remote-exec-command' import { uploadRelayDirectory } from './remote-install-transfers' import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform' import { pinnedNodeRuntimeAsset } from '../../../shared/node-runtime-pin' +import { DesktopDownloadUnreachableError } from '../../node-runtime/pinned-runtime-materializer' import { HOST_NODE_DOWNLOAD_FAILED, HOST_NODE_DOWNLOAD_OK, @@ -100,9 +101,30 @@ describe('pinned Node when this computer cannot download it', () => { expect(message).toContain('net::ERR_CONNECTION_REFUSED') expect(message).toContain('curl: (7) Failed to connect to nodejs.org') expect(message).toContain(OFFLINE_PATH) + expect(message).toContain('Settings > Advanced > Network > Node.js download mirror') + expect(message).toContain("NODEJS_ORG_MIRROR in this computer's environment") expect(uploadRelayDirectory).not.toHaveBeenCalled() }) + it("quotes only this computer's short reason, not its standalone advice", async () => { + freshHost(`${HOST_NODE_DOWNLOAD_FAILED}\nnodejs.org: curl exit 7`) + const desktop = () => + Promise.reject( + new DesktopDownloadUnreachableError( + "This computer couldn't download Node ... so the host doesn't need internet access", + 'https://nodejs.org/dist/x: net::ERR_CONNECTION_REFUSED', + new Error('net::ERR_CONNECTION_REFUSED') + ) + ) + + const message = String(await ensure(desktop).catch((error: unknown) => error)) + + expect(message).toContain( + 'This computer: https://nodejs.org/dist/x: net::ERR_CONNECTION_REFUSED.' + ) + expect(message).not.toContain("the host doesn't need internet access") + }) + it('does not try the host once the connect was cancelled', async () => { const commands = freshHost(HOST_NODE_DOWNLOAD_OK) const controller = new AbortController() diff --git a/src/main/managed-server/install/remote-node-runtime-host-download.test.ts b/src/main/managed-server/install/remote-node-runtime-host-download.test.ts index c912195da26..94cac163ace 100644 --- a/src/main/managed-server/install/remote-node-runtime-host-download.test.ts +++ b/src/main/managed-server/install/remote-node-runtime-host-download.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { runProcessSync } from '@orca/process-host' -import { NODE_RUNTIME_ASSETS } from '../../../shared/node-runtime-pin' +import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_PIN } from '../../../shared/node-runtime-pin' import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform' import { hostDownloadNodeRuntimeCommand, @@ -43,7 +43,7 @@ function fakeHost(tools: Record): { bin: string; stage: string } const stage = join(root, 'stage') mkdirSync(bin) mkdirSync(stage) - for (const basic of ['cut', 'rm', 'tail', 'sha256sum', 'shasum', 'perl']) { + for (const basic of ['cut', 'rm', 'head', 'sleep', 'kill', 'sha256sum', 'shasum', 'perl']) { const resolved = which(basic) if (resolved) { symlinkSync(resolved, join(bin, basic)) @@ -59,48 +59,72 @@ function fakeHost(tools: Record): { bin: string; stage: string } const writeTo = (flag: string, content: string): string => `while [ $# -gt 0 ]; do if [ "$1" = ${flag} ]; then printf '%s' '${content}' > "$2"; fi; shift; done` -function runOnHost(bin: string, stage: string): string { +function runOnHost( + bin: string, + stage: string, + options: { mirror?: string; env?: Record; timeoutMs?: number } = {} +): string { + const command = hostDownloadNodeRuntimeCommand(host, { + stageDir: stage, + target: TARGET, + mirror: options.mirror + }) const result = runProcessSync({ program: '/bin/sh', - args: ['-c', hostDownloadNodeRuntimeCommand(host, { stageDir: stage, target: TARGET })], - env: { PATH: bin }, - timeoutMs: 30_000 + args: ['-c', command], + env: { PATH: bin, ...options.env }, + timeoutMs: options.timeoutMs ?? 30_000 }) expect(result.code, result.stderr).toBe(0) return result.stdout } +const read = (bin: string, name: string): string[] => + runProcessSync({ program: '/bin/cat', args: [join(bin, name)] }) + .stdout.trim() + .split('\n') +const mirrored = (base: string): string => + `${base}/v${NODE_RUNTIME_PIN.version}/${originalAsset.archive}` +// Records each URL and --max-time, and serves only URLs with the given prefix. +const curlServing = (prefix: string, failure = "echo 'curl: (22) 404' >&2; exit 22"): string => + [ + `prev=; for a in "$@"; do case "$a" in http*) echo "$a" >> "\${0%/*}/urls";; esac;`, + `[ "$prev" = --max-time ] && echo "$a" >> "\${0%/*}/max-times"; prev=$a; done`, + `ulimit -f >> "\${0%/*}/file-limit"`, + `for a in "$@"; do case "$a" in ${prefix}*) ${writeTo('-o', PAYLOAD)}; exit 0;; esac; done`, + failure + ].join('\n') + describe.skipIf(process.platform === 'win32')('the host-side pinned Node download', () => { - it('downloads the pinned URL with curl and keeps the verified archive', () => { - const { bin, stage } = fakeHost({ - curl: `echo "$@" > "\${0%/*}/curl.args"\n${writeTo('-o', PAYLOAD)}` - }) + it('downloads the release URL with curl and keeps the verified archive', () => { + const { bin, stage } = fakeHost({ curl: curlServing('https://nodejs.org/') }) expect(readHostNodeDownload(runOnHost(bin, stage))).toEqual({ ok: true }) expect(existsSync(join(stage, originalAsset.archive))).toBe(true) - expect( - runProcessSync({ program: '/bin/cat', args: [join(bin, 'curl.args')] }).stdout - ).toContain(hostNodeRuntimeUrl(TARGET)) + expect(read(bin, 'urls')).toEqual([hostNodeRuntimeUrl(TARGET)]) + // 200 MiB in 512-byte blocks: an endless response can't fill the host's disk. + expect(read(bin, 'file-limit')).toEqual(['409600']) }) - it('falls back to wget when curl fails', () => { - const { bin, stage } = fakeHost({ - curl: "echo 'curl: (35) TLS connect error' >&2; exit 35", - wget: writeTo('-O', PAYLOAD) - }) + it('uses wget where the host has no curl', () => { + const { bin, stage } = fakeHost({ wget: writeTo('-O', PAYLOAD) }) expect(readHostNodeDownload(runOnHost(bin, stage))).toEqual({ ok: true }) }) - it('does not start wget after curl ran out of time, so both fit the exec limit', () => { - const { bin, stage } = fakeHost({ - curl: "echo 'curl: (28) Operation timed out' >&2; exit 28", - wget: `touch "\${0%/*}/wget.ran"; ${writeTo('-O', PAYLOAD)}` - }) - expect(readHostNodeDownload(runOnHost(bin, stage))).toMatchObject({ - ok: false, - report: expect.stringContaining('exit 28') - }) - expect(existsSync(join(bin, 'wget.ran'))).toBe(false) - }) + it( + 'stops a trickling wget at its share of the deadline, without the timeout command', + { timeout: 90_000 }, + () => { + // Deadline 1840; every later reading leaves 31 s. + const { bin, stage } = fakeHost({ + date: `if [ -e "\${0%/*}/clock" ]; then echo 1809; else : > "\${0%/*}/clock"; echo 1000; fi`, + wget: 'exec sleep 120' + }) + const started = Date.now() + const result = readHostNodeDownload(runOnHost(bin, stage, { timeoutMs: 80_000 })) + expect(Date.now() - started).toBeLessThan(70_000) + expect(result).toMatchObject({ ok: false, report: expect.stringContaining('wget exit 143') }) + } + ) it('discards a file that is not the pinned release', () => { const { bin, stage } = fakeHost({ curl: writeTo('-o', 'blocked by policy') }) @@ -109,14 +133,14 @@ describe.skipIf(process.platform === 'win32')('the host-side pinned Node downloa expect(existsSync(join(stage, originalAsset.archive))).toBe(false) }) - it("reports the tool's own words when every download fails", () => { + it("reports each source by name with the tool's own words", () => { const { bin, stage } = fakeHost({ curl: "echo 'curl: (7) Failed to connect to nodejs.org port 443' >&2; exit 7" }) - const result = readHostNodeDownload(runOnHost(bin, stage)) - expect(result).toEqual({ + expect(readHostNodeDownload(runOnHost(bin, stage))).toEqual({ ok: false, - report: 'its download failed (exit 7): curl: (7) Failed to connect to nodejs.org port 443' + report: + 'its downloads failed (nodejs.org: curl exit 7, curl: (7) Failed to connect to nodejs.org port 443)' }) }) @@ -127,4 +151,44 @@ describe.skipIf(process.platform === 'win32')('the host-side pinned Node downloa report: 'it has neither curl nor wget' }) }) + + it("tries this computer's mirror first and stops once it serves the pinned file", () => { + const { bin, stage } = fakeHost({ curl: curlServing('https://desk.example/') }) + const output = runOnHost(bin, stage, { mirror: 'https://desk.example/dist' }) + expect(readHostNodeDownload(output)).toEqual({ ok: true }) + expect(read(bin, 'urls')).toEqual([mirrored('https://desk.example/dist')]) + }) + + it("ignores the host's own mirror variables, so only URLs Orca validated are fetched", () => { + const { bin, stage } = fakeHost({ curl: curlServing('https://nodejs.org/') }) + const output = runOnHost(bin, stage, { + mirror: 'https://desk.example/dist', + env: { NODEJS_ORG_MIRROR: 'https://user:secret@host.example/dist[' } + }) + expect(readHostNodeDownload(output)).toEqual({ ok: true }) + expect(read(bin, 'urls')).toEqual([ + mirrored('https://desk.example/dist'), + hostNodeRuntimeUrl(TARGET) + ]) + }) + + it('splits the deadline over the sources left, so a stalled mirror leaves time for the release', () => { + const { bin, stage } = fakeHost({ + date: 'echo 1000', + curl: curlServing('https://nodejs.org/', "echo 'curl: (28) timed out' >&2; exit 28") + }) + const output = runOnHost(bin, stage, { mirror: 'https://desk.example/dist' }) + expect(readHostNodeDownload(output)).toEqual({ ok: true }) + expect(read(bin, 'max-times')).toEqual(['420', '840']) + }) + + it('reports a source as not tried once the deadline has passed', () => { + const { bin, stage } = fakeHost({ + date: `if [ -e "\${0%/*}/clock" ]; then echo 99999; else : > "\${0%/*}/clock"; echo 1000; fi`, + curl: `touch "\${0%/*}/curl.ran"; exit 7` + }) + const result = readHostNodeDownload(runOnHost(bin, stage)) + expect(result).toMatchObject({ ok: false, report: expect.stringContaining('out of time') }) + expect(existsSync(join(bin, 'curl.ran'))).toBe(false) + }) }) diff --git a/src/main/managed-server/install/remote-node-runtime-host-download.ts b/src/main/managed-server/install/remote-node-runtime-host-download.ts index d0ee689b844..174f0f405e1 100644 --- a/src/main/managed-server/install/remote-node-runtime-host-download.ts +++ b/src/main/managed-server/install/remote-node-runtime-host-download.ts @@ -6,31 +6,37 @@ */ import { copyFile, link, mkdtemp } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' +import { nodeDistMirrorUrl } from '../../../shared/node-dist-mirror' import { + NODE_RUNTIME_PIN, nodeRuntimeReleaseUrl, pinnedNodeRuntimeAsset, type NodeRuntimeTarget } from '../../../shared/node-runtime-pin' import { quotePosixShell } from '../../../shared/shell-argument-quoting' -import { - parseRemoteRuntimeExitReport, - REMOTE_NODE_RUNTIME_EXIT_PREFIX -} from '../../ssh/orcad-remote-node-runtime-report' import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from '../../ssh/ssh-remote-platform' +import { configuredNodeDistMirror } from '../../node-runtime/node-dist-mirror-setting' +import { DesktopDownloadUnreachableError } from '../../node-runtime/pinned-runtime-materializer' import { remoteSha256Command } from './remote-sha256-command' export const HOST_NODE_DOWNLOAD_OK = 'ORCA_NODE_HOST_DOWNLOAD_OK' export const HOST_NODE_DOWNLOAD_FAILED = 'ORCA_NODE_HOST_DOWNLOAD_FAILED' export const HOST_NODE_DOWNLOAD_NO_TOOL = 'ORCA_NODE_HOST_DOWNLOAD_NO_TOOL' -export const HOST_NODE_DOWNLOAD_HASH_MISMATCH = 'ORCA_NODE_HOST_DOWNLOAD_HASH_MISMATCH' -/** Each tool's wall-clock cap: ~140 KB/s still fetches the ~58 MB archive. */ -const HOST_DOWNLOAD_TOOL_SECONDS = 420 -/** Both tools plus the hash, so a tool's own limit always reports before the exec gives up. */ -export const HOST_NODE_DOWNLOAD_TIMEOUT_MS = (2 * HOST_DOWNLOAD_TOOL_SECONDS + 60) * 1000 +/** One wall-clock budget for every source: ~70 KB/s still fetches the ~58 MB archive. */ +const HOST_DOWNLOAD_BUDGET_SECONDS = 840 +/** Below this an attempt can't finish, so the source is reported as not tried. */ +const HOST_DOWNLOAD_MIN_ATTEMPT_SECONDS = 30 +/** The desktop downloader's 200 MiB limit, in the 512-byte blocks `ulimit -f` counts. */ +const HOST_DOWNLOAD_MAX_BLOCKS = (200 * 1024 * 1024) / 512 +/** The budget plus the hash and the report, so the script always answers before the exec gives up. */ +export const HOST_NODE_DOWNLOAD_TIMEOUT_MS = (HOST_DOWNLOAD_BUDGET_SECONDS + 120) * 1000 + +const HOST_NODE_DOWNLOAD_NOT_PINNED = + 'the file is not the pinned release (a proxy or filter may have replaced it, or the host lacks sha256sum and shasum)' export function hostNodeRuntimeUrl(target: NodeRuntimeTarget): string { const asset = pinnedNodeRuntimeAsset(target) @@ -38,33 +44,64 @@ export function hostNodeRuntimeUrl(target: NodeRuntimeTarget): string { } /** - * Downloads into the installer's stage with curl, else wget, keeping the file only if it hashes - * to the pin. Always exits 0 with a marker, so the caller gets the tool's own words. + * Downloads into the installer's stage from this computer's mirror, then the release URL, with + * curl or else wget. Only URLs Orca validated are used, so no host secret can reach the report. + * Keeps the file only if it hashes to the pin, and always exits 0 with a marker. */ export function hostDownloadNodeRuntimeCommand( host: RemoteHostPlatform, - args: { stageDir: string; target: NodeRuntimeTarget } + args: { stageDir: string; target: NodeRuntimeTarget; mirror?: string | null } ): string { const asset = pinnedNodeRuntimeAsset(args.target) const file = quotePosixShell(joinRemotePath(host, args.stageDir, asset.archive)) - const url = quotePosixShell(hostNodeRuntimeUrl(args.target)) - const curl = `curl -fsSL --connect-timeout 20 --max-time ${HOST_DOWNLOAD_TOOL_SECONDS} -o ${file} ${url}` - // wget has no total-time limit of its own. Probed by running it: old busybox wants `-t SECS`. - const wget = `$orca_dl_timeout wget -q -T 20 -t 2 -O ${file} ${url}` + // Mirrors carry the official tree only; unofficial builds live at one address. + const official = asset.source === 'official' + const desktopMirror = + official && args.mirror + ? nodeDistMirrorUrl(args.mirror, NODE_RUNTIME_PIN.version, asset.archive) + : null + const sources = [ + desktopMirror + ? `orca_dl_get 'the mirror set on this computer' ${quotePosixShell(desktopMirror)}` + : null, + `orca_dl_get ${quotePosixShell(new URL(hostNodeRuntimeUrl(args.target)).host)} ${quotePosixShell(hostNodeRuntimeUrl(args.target))}` + ].filter((line): line is string => line !== null) + const min = HOST_DOWNLOAD_MIN_ATTEMPT_SECONDS + // Why ulimit: a proxy or mirror serving endless bytes would otherwise fill the host's disk. + const sizeCap = `ulimit -f ${HOST_DOWNLOAD_MAX_BLOCKS} 2>/dev/null` + // wget has no total-time limit, and `timeout` varies by host, so a backgrounded sleep bounds it. + // The watchdog's output goes to /dev/null so it never holds the capture or the SSH channel open. + const wget = + `wget -q -T 20 -t 1 -O ${file} "$2" & orca_dl_pid=$!; ` + + // The trap takes the sleep down with the watchdog, so nothing lingers once wget is done. + `(trap 'kill "$orca_dl_z" 2>/dev/null; exit 0' TERM; sleep "$orca_dl_t" & orca_dl_z=$!; ` + + 'wait "$orca_dl_z" && kill "$orca_dl_pid") /dev/null 2>&1 & orca_dl_dog=$!; ' + + 'wait "$orca_dl_pid"; orca_dl_s=$?; kill "$orca_dl_dog" 2>/dev/null; exit "$orca_dl_s"' + const curl = `curl -fsSLg --connect-timeout 20 --max-time "$orca_dl_t" -o ${file} "$2"` return [ - 'orca_dl_status=127; orca_dl_out=; orca_dl_timeout=', - `timeout 5 true >/dev/null 2>&1 && orca_dl_timeout="timeout ${HOST_DOWNLOAD_TOOL_SECONDS}"`, - `if command -v curl >/dev/null 2>&1; then orca_dl_out=$(${curl} 2>&1); orca_dl_status=$?; fi`, - // Why wget after a failed curl: a curl built without TLS or proxy support can fail where wget - // works. Not after curl's timeout (28): the link is just slow, and wget would be too. - `if [ "$orca_dl_status" != 0 ] && [ "$orca_dl_status" != 28 ] && command -v wget >/dev/null 2>&1; then ` + - `orca_dl_out="$orca_dl_out\${orca_dl_out:+; }$(${wget} 2>&1)"; orca_dl_status=$?; fi`, - `if [ "$orca_dl_status" = 127 ] && [ -z "$orca_dl_out" ]; then echo ${HOST_NODE_DOWNLOAD_NO_TOOL}`, - `elif [ "$orca_dl_status" != 0 ]; then rm -f ${file}; echo ${HOST_NODE_DOWNLOAD_FAILED}; ` + - `echo "${REMOTE_NODE_RUNTIME_EXIT_PREFIX}$orca_dl_status"; printf '%s\\n' "$orca_dl_out" | tail -c 2000`, - `elif [ "$(${remoteSha256Command(file)})" != ${quotePosixShell(asset.archiveSha256)} ]; then ` + - `rm -f ${file}; echo ${HOST_NODE_DOWNLOAD_HASH_MISMATCH}`, - `else echo ${HOST_NODE_DOWNLOAD_OK}; fi` + 'orca_dl_ok=; orca_dl_out=; orca_dl_tool=', + 'if command -v curl >/dev/null 2>&1; then orca_dl_tool=curl; elif command -v wget >/dev/null 2>&1; then orca_dl_tool=wget; fi', + `orca_dl_n=${sources.length}`, + 'orca_dl_end=$(date +%s 2>/dev/null)', + `case "$orca_dl_end" in ''|*[!0-9]*) orca_dl_end= ;; *) orca_dl_end=$((orca_dl_end + ${HOST_DOWNLOAD_BUDGET_SECONDS})) ;; esac`, + // Each source gets an even share of what is left, so a stalled mirror can't starve the release + // URL, and the last source inherits whatever the others didn't use. + 'orca_dl_get() {', + ' [ -z "$orca_dl_ok" ] || return 0', + ` if [ -n "$orca_dl_end" ]; then orca_dl_t=$(((orca_dl_end - $(date +%s)) / orca_dl_n)); else orca_dl_t=${Math.floor(HOST_DOWNLOAD_BUDGET_SECONDS / sources.length)}; fi`, + ' orca_dl_n=$((orca_dl_n - 1))', + ` if [ "$orca_dl_t" -lt ${min} ]; then orca_dl_out="$orca_dl_out\${orca_dl_out:+; }$1: not tried, out of time"; return 0; fi`, + ` if [ "$orca_dl_tool" = curl ]; then orca_dl_said=$( (${sizeCap}; ${curl}) 2>&1 ); orca_dl_try=$?`, + ` else orca_dl_said=$( (${sizeCap}; ${wget}) 2>&1 ); orca_dl_try=$?; fi`, + ` if [ "$orca_dl_try" = 0 ] && [ "$(${remoteSha256Command(file)})" = ${quotePosixShell(asset.archiveSha256)} ]; then orca_dl_ok=1; return 0; fi`, + ` rm -f ${file}`, + ` [ "$orca_dl_try" = 0 ] && orca_dl_said='${HOST_NODE_DOWNLOAD_NOT_PINNED}'`, + ' orca_dl_out="$orca_dl_out${orca_dl_out:+; }$1: $orca_dl_tool exit $orca_dl_try${orca_dl_said:+, $orca_dl_said}"', + '}', + `if [ -z "$orca_dl_tool" ]; then echo ${HOST_NODE_DOWNLOAD_NO_TOOL}; else`, + ...sources, + `if [ -n "$orca_dl_ok" ]; then echo ${HOST_NODE_DOWNLOAD_OK}; else echo ${HOST_NODE_DOWNLOAD_FAILED}; printf '%s\\n' "$orca_dl_out" | head -c 3000; fi`, + 'fi' ].join('\n') } @@ -79,20 +116,10 @@ export function readHostNodeDownload(output: string): HostNodeDownloadResult { if (lines.some((line) => line.trim() === HOST_NODE_DOWNLOAD_NO_TOOL)) { return { ok: false, report: 'it has neither curl nor wget' } } - if (lines.some((line) => line.trim() === HOST_NODE_DOWNLOAD_HASH_MISMATCH)) { - return { - ok: false, - report: - "it couldn't verify the file as the pinned release, so it was discarded (a proxy or filter may have replaced it, or the host lacks sha256sum and shasum)" - } - } const failed = output.indexOf(HOST_NODE_DOWNLOAD_FAILED) if (failed !== -1) { - const { exitStatus, output: words } = parseRemoteRuntimeExitReport(output.slice(failed)) - return { - ok: false, - report: `its download failed (exit ${exitStatus ?? 'unknown'})${words ? `: ${words}` : ''}` - } + const words = output.slice(failed + HOST_NODE_DOWNLOAD_FAILED.length).trim() + return { ok: false, report: `its downloads failed${words ? ` (${words})` : ''}` } } return { ok: false, report: `it gave an unexpected reply: ${output.trim().slice(0, 500)}` } } @@ -103,16 +130,26 @@ export class NodeRuntimeUnreachableError extends Error { readonly url: string, readonly hostReport: string, readonly offlineArchivePath: string | undefined, - cause: unknown + cause: unknown, + mirrorable = true ) { - const desktop = cause instanceof Error ? cause.message : String(cause) + const desktop = + cause instanceof DesktopDownloadUnreachableError + ? cause.reason + : cause instanceof Error + ? cause.message + : String(cause) + const mirror = mirrorable + ? ', set a mirror of nodejs.org/dist in Settings > Advanced > Network > Node.js download mirror ' + + "(or NODEJS_ORG_MIRROR in this computer's environment)" + : '' const offline = offlineArchivePath ? `, or download that file on another machine and copy it to ${offlineArchivePath} on this computer.` : '.' super( `Neither this computer nor the host could download Node from ${url}. ` + `This computer: ${desktop}. The host: ${hostReport}. ` + - `Check the network or proxy on either machine and connect again${offline}`, + `Check the network or proxy on either machine and connect again${mirror}${offline}`, { cause } ) this.name = 'NodeRuntimeUnreachableError' @@ -134,7 +171,11 @@ export async function downloadNodeRuntimeOnHost(args: { signal?: AbortSignal }): Promise { const output = await args.exec( - hostDownloadNodeRuntimeCommand(args.host, { stageDir: args.stageDir, target: args.target }), + hostDownloadNodeRuntimeCommand(args.host, { + stageDir: args.stageDir, + target: args.target, + mirror: configuredNodeDistMirror() + }), { signal: args.signal, timeoutMs: args.timeoutMs } ) const result = readHostNodeDownload(output) @@ -143,7 +184,8 @@ export async function downloadNodeRuntimeOnHost(args: { hostNodeRuntimeUrl(args.target), result.report, args.offlineArchivePath, - args.desktopError + args.desktopError, + pinnedNodeRuntimeAsset(args.target).source === 'official' ) } console.warn( diff --git a/src/main/node-runtime/node-dist-mirror-setting.ts b/src/main/node-runtime/node-dist-mirror-setting.ts new file mode 100644 index 00000000000..65d9bd66cd9 --- /dev/null +++ b/src/main/node-runtime/node-dist-mirror-setting.ts @@ -0,0 +1,15 @@ +/** This computer's Node mirror: the Settings value, else the conventional environment variables. */ +import { resolveNodeDistMirror } from '../../shared/node-dist-mirror' + +let readMirrorSetting: (() => string | undefined) | null = null + +/** Registered once the settings store exists; read on every download, so edits apply at once. */ +export function setNodeDistMirrorSettingReader(read: (() => string | undefined) | null): void { + readMirrorSetting = read +} + +export function configuredNodeDistMirror( + env: Record = process.env +): string | null { + return resolveNodeDistMirror(readMirrorSetting?.(), env) +} diff --git a/src/main/node-runtime/pinned-runtime-materializer-mirror.test.ts b/src/main/node-runtime/pinned-runtime-materializer-mirror.test.ts new file mode 100644 index 00000000000..5705a620681 --- /dev/null +++ b/src/main/node-runtime/pinned-runtime-materializer-mirror.test.ts @@ -0,0 +1,117 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' +import { + DesktopDownloadUnreachableError, + materializeNodeRuntimeArchive +} from './pinned-runtime-materializer' +import { ReleaseMismatchError } from './runtime-archive-download' +import { setNodeDistMirrorSettingReader } from './node-dist-mirror-setting' + +const TARGET = 'linux-x64-glibc' +const MIRROR = 'https://artifacts.example.internal/nodejs/dist' +const BODY = new TextEncoder().encode('pinned archive') +const originalAsset = { ...NODE_RUNTIME_ASSETS[TARGET] } +const mirrorUrl = `${MIRROR}/v${NODE_RUNTIME_PIN.version}/${originalAsset.archive}` +let root = '' + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-node-mirror-')) + Object.assign(NODE_RUNTIME_ASSETS[TARGET], { + archiveSha256: createHash('sha256').update(BODY).digest('hex') + }) + setNodeDistMirrorSettingReader(() => `${MIRROR}/`) +}) + +afterEach(async () => { + setNodeDistMirrorSettingReader(null) + Object.assign(NODE_RUNTIME_ASSETS[TARGET], originalAsset) + await rm(root, { recursive: true, force: true }) +}) + +function fetcher(serves: (url: string) => boolean) { + return vi.fn(async (url) => + serves(String(url)) + ? new Response(Buffer.from(BODY), { status: 200 }) + : new Response('not here', { status: 404 }) + ) +} + +describe('the pinned Node download with a mirror configured', () => { + it('downloads from the mirror and never asks nodejs.org', async () => { + const fetch = fetcher((url) => url === mirrorUrl) + + await materializeNodeRuntimeArchive(TARGET, root, { fetcher: fetch }) + + expect(fetch.mock.calls.map(([url]) => String(url))).toEqual([mirrorUrl]) + }) + + it('falls back to the release URL when the mirror lacks the file', async () => { + const fetch = fetcher((url) => url.startsWith('https://nodejs.org/')) + + await materializeNodeRuntimeArchive(TARGET, root, { fetcher: fetch }) + + const urls = fetch.mock.calls.map(([url]) => String(url)) + expect(urls[0]).toBe(mirrorUrl) + expect(urls.at(-1)).toMatch(/^https:\/\/nodejs\.org\/dist\//) + }) + + it('tries an unreachable mirror once, without its retry pauses, before nodejs.org', async () => { + const fetch = vi.fn(async (url) => { + if (String(url) === mirrorUrl) { + throw new Error('net::ERR_CONNECTION_TIMED_OUT') + } + return new Response(Buffer.from(BODY), { status: 200 }) + }) + + await materializeNodeRuntimeArchive(TARGET, root, { fetcher: fetch }) + + expect(fetch.mock.calls.map(([url]) => String(url) === mirrorUrl)).toEqual([true, false]) + }) + + it('rejects a mirror file that fails the pin, whatever the mirror says', async () => { + const fetch = vi.fn( + async () => new Response('blocked', { status: 200 }) + ) + + await expect( + materializeNodeRuntimeArchive(TARGET, root, { fetcher: fetch }) + ).rejects.toBeInstanceOf(ReleaseMismatchError) + }) + + it('names both addresses when neither is reachable', async () => { + const fetch = vi.fn(async () => { + throw new Error('net::ERR_CONNECTION_REFUSED') + }) + + const error = await materializeNodeRuntimeArchive(TARGET, root, { + fetcher: fetch, + retryDelaysMs: [] + }).catch((failure: unknown) => failure) + + expect(error).toBeInstanceOf(DesktopDownloadUnreachableError) + expect(String(error)).toMatch(/the mirror .* failed too/) + }) + + it('keeps a cancel during the release attempt a cancel', async () => { + const controller = new AbortController() + const fetch = vi.fn(async (url) => { + if (String(url) !== mirrorUrl) { + controller.abort() + } + throw new Error('net::ERR_CONNECTION_REFUSED') + }) + + const error = await materializeNodeRuntimeArchive(TARGET, root, { + fetcher: fetch, + signal: controller.signal, + retryDelaysMs: [] + }).catch((failure: unknown) => failure) + + expect(error).not.toBeInstanceOf(DesktopDownloadUnreachableError) + expect(controller.signal.aborted).toBe(true) + }) +}) diff --git a/src/main/node-runtime/pinned-runtime-materializer.ts b/src/main/node-runtime/pinned-runtime-materializer.ts index 3beae40892c..9d66ff21ecb 100644 --- a/src/main/node-runtime/pinned-runtime-materializer.ts +++ b/src/main/node-runtime/pinned-runtime-materializer.ts @@ -11,7 +11,10 @@ import { } from './runtime-archive-download' import { findOrcadCachePath } from './orcad-cache-path' import { orcadNodeRuntimeExecutable } from '../../shared/orcad-artifacts' +import { nodeDistMirrorUrl } from '../../shared/node-dist-mirror' +import { configuredNodeDistMirror } from './node-dist-mirror-setting' import { + NODE_RUNTIME_PIN, pinnedNodeRuntimeAsset, nodeRuntimeExecutablePath, nodeRuntimeReleaseUrl, @@ -21,6 +24,7 @@ import { export type PinnedRuntimeMaterializeOptions = { fetcher?: MainHttpClient['fetch'] signal?: AbortSignal + /** Pauses between download attempts; empty makes a single attempt. */ retryDelaysMs?: readonly number[] } @@ -104,16 +108,30 @@ function unreachableArchiveError( return error } const cause = error instanceof Error ? error.message : String(error) - return new Error( + return new DesktopDownloadUnreachableError( `This computer couldn't download ${archive.label} from ${archive.url} (${cause}). ` + `Orca uploads ${archive.label} to the host from this computer, so the host doesn't need ` + `internet access, but this computer must reach that address once. Check this computer's ` + - `network connection or proxy and connect again, or download that file on another machine ` + + `network connection or proxy and connect again, set a mirror in Settings > Advanced > ` + + `Network > Node.js download mirror, or download that file on another machine ` + `and copy it to ${cachePath}`, - { cause: error } + `${archive.url}: ${cause}`, + error ) } +/** This computer's download failed on the network; `reason` is the short form for combined errors. */ +export class DesktopDownloadUnreachableError extends Error { + constructor( + message: string, + readonly reason: string, + cause: unknown + ) { + super(message, { cause }) + this.name = 'DesktopDownloadUnreachableError' + } +} + export async function materializeCachedRuntimeExecutable( runtime: PinnedRuntimeExecutable, options: PinnedRuntimeMaterializeOptions @@ -199,16 +217,61 @@ function nodeRuntimeArchive(target: NodeRuntimeTarget): PinnedRuntimeArchive { } } +/** Tries the configured mirror before the release URL; the pinned hash decides what is kept. */ +async function withNodeDistMirror( + target: NodeRuntimeTarget, + options: PinnedRuntimeMaterializeOptions, + run: (archive: PinnedRuntimeArchive, options: PinnedRuntimeMaterializeOptions) => Promise +): Promise { + const archive = nodeRuntimeArchive(target) + const asset = pinnedNodeRuntimeAsset(target) + // Mirrors carry the official tree only; unofficial builds live at one address. + const mirror = asset.source === 'official' ? configuredNodeDistMirror() : null + if (!mirror) { + return run(archive, options) + } + const mirrorUrl = nodeDistMirrorUrl(mirror, NODE_RUNTIME_PIN.version, asset.archive) + try { + // One attempt: an unreachable mirror must not hold up the release URL behind its retries. + return await run({ ...archive, url: mirrorUrl }, { ...options, retryDelaysMs: [] }) + } catch (mirrorError) { + if (options.signal?.aborted) { + throw mirrorError + } + return run(archive, options).catch((error: unknown) => { + // Only two network failures combine; a cancel or a local error keeps its own identity. + if (options.signal?.aborted || !(error instanceof DesktopDownloadUnreachableError)) { + throw error + } + const mirrorReason = + mirrorError instanceof DesktopDownloadUnreachableError + ? mirrorError.reason + : `${mirrorUrl}: ${errorText(mirrorError)}` + throw new DesktopDownloadUnreachableError( + `${errorText(error)} (the mirror ${mirrorReason} failed too)`, + `${mirrorReason}; ${errorReason(error)}`, + error + ) + }) + } +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function errorReason(error: unknown): string { + return error instanceof DesktopDownloadUnreachableError ? error.reason : errorText(error) +} + /** The pinned official archive, uploaded to hosts as published (design D5). */ export function materializeNodeRuntimeArchive( target: NodeRuntimeTarget, cacheRoot: string, options: PinnedRuntimeMaterializeOptions ): Promise { - return materializeVerifiedRuntimeArchive( - nodeRuntimeArchive(target), - nodeRuntimeArchiveDir(cacheRoot), - options + return withNodeDistMirror(target, options, (archive, download) => + materializeVerifiedRuntimeArchive(archive, nodeRuntimeArchiveDir(cacheRoot), download) ) } @@ -232,21 +295,23 @@ export function materializeCachedNodeRuntime( ): Promise { const asset = pinnedNodeRuntimeAsset(target) const executableName = basename(orcadNodeRuntimeExecutable(target)) - return materializeCachedRuntimeExecutable( - { - ...nodeRuntimeArchive(target), - executableSha256: asset.executableSha256, - member: nodeRuntimeExecutablePath(target, asset.archive), - isWindows: target.startsWith('win32-'), - cachePath: (attempt) => - join( - cacheRoot, - 'node', - asset.executableSha256, - `${attempt ? `repair-${attempt}-` : ''}${executableName}` - ) - }, - options + return withNodeDistMirror(target, options, (archive, download) => + materializeCachedRuntimeExecutable( + { + ...archive, + executableSha256: asset.executableSha256, + member: nodeRuntimeExecutablePath(target, asset.archive), + isWindows: target.startsWith('win32-'), + cachePath: (attempt) => + join( + cacheRoot, + 'node', + asset.executableSha256, + `${attempt ? `repair-${attempt}-` : ''}${executableName}` + ) + }, + download + ) ) } diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 05f97cb518c..38a32072b1e 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -4,6 +4,7 @@ import { applyBackgroundActivationPolicy } from '../window/foreground-activation import { applyElectronProxySettings } from '../network/proxy-settings' import { installElectronProxyRequestGuard } from '../network/electron-proxy-request-guard' import { handleElectronProxyLogin } from '../network/electron-proxy-credentials' +import { setNodeDistMirrorSettingReader } from '../node-runtime/node-dist-mirror-setting' import { handleBrowserGuestLogin } from '../browser/browser-http-auth-prompt' import { installMainThreadHangWatchdog } from '../hang-watchdog/main-thread-hang-watchdog' import { preservePreviousHangDetection } from '../hang-watchdog/previous-hang-detection' @@ -149,6 +150,7 @@ export async function initializeReadyFoundation(): Promise { } const store = profileState.store state.store = store + setNodeDistMirrorSettingReader(() => store.getSettings().nodeDistMirrorUrl) // Why before any handler or runtime: a project a WSL move is taking must never get a new terminal. loadWslMoveFence(getAppEnvironment().getPath('userData')) // Finishes a move that committed before a crash; gated off it does nothing, and it never throws. diff --git a/src/renderer/src/components/settings/AdvancedNetworkSettingsSection.interaction.test.tsx b/src/renderer/src/components/settings/AdvancedNetworkSettingsSection.interaction.test.tsx index 513c2ed4e4e..a8808edcd37 100644 --- a/src/renderer/src/components/settings/AdvancedNetworkSettingsSection.interaction.test.tsx +++ b/src/renderer/src/components/settings/AdvancedNetworkSettingsSection.interaction.test.tsx @@ -61,3 +61,65 @@ describe('AdvancedNetworkSettingsSection bypass rules control', () => { expect(updateSettings).not.toHaveBeenCalled() }) }) + +describe('AdvancedNetworkSettingsSection Node.js download mirror', () => { + it('shows a saved mirror and commits a normalized new one on blur', () => { + const updateSettings = vi.fn() + const { container } = render( + + ) + const input = container.querySelector('#settings-node-dist-mirror-url')! + expect(input.value).toBe('https://old.example/dist') + + fireEvent.change(input, { target: { value: ' https://npmmirror.com/mirrors/node/ ' } }) + fireEvent.blur(input) + + expect(updateSettings).toHaveBeenCalledWith({ + nodeDistMirrorUrl: 'https://npmmirror.com/mirrors/node' + }) + }) + + it('keeps an invalid mirror out of settings and says why', () => { + const updateSettings = vi.fn() + const { container } = render( + + ) + const input = container.querySelector('#settings-node-dist-mirror-url')! + + fireEvent.change(input, { target: { value: 'https://user:pw@mirror.example/dist' } }) + fireEvent.blur(input) + + expect(updateSettings).not.toHaveBeenCalled() + expect(container.textContent).toContain('cannot contain a user name or password') + }) + + it('follows settings reloads A to B and back to A', () => { + const updateSettings = vi.fn() + const base = getDefaultSettings('/tmp') + const a = 'https://a.example/dist' + const b = 'https://b.example/dist' + const view = (mirror: string) => ( + + ) + const { container, rerender } = render(view(a)) + const input = () => container.querySelector('#settings-node-dist-mirror-url')! + + fireEvent.change(input(), { target: { value: b } }) + fireEvent.blur(input()) + rerender(view(b)) + rerender(view(a)) + + expect(input().value).toBe(a) + fireEvent.blur(input()) + expect(updateSettings).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/settings/AdvancedNetworkSettingsSection.tsx b/src/renderer/src/components/settings/AdvancedNetworkSettingsSection.tsx index 34939b067f4..3b5e06c0bde 100644 --- a/src/renderer/src/components/settings/AdvancedNetworkSettingsSection.tsx +++ b/src/renderer/src/components/settings/AdvancedNetworkSettingsSection.tsx @@ -11,6 +11,7 @@ import { Input } from '../ui/input' import { Label } from '../ui/label' import { Textarea } from '../ui/textarea' import { getAdvancedNetworkSearchEntries } from './advanced-network-search' +import { NodeDistMirrorSettingField } from './NodeDistMirrorSettingField' import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search' import { translate } from '@/i18n/i18n' @@ -25,7 +26,11 @@ export function shouldOpenNetworkProxyConfig(searchQuery: string): boolean { /** A configured proxy should also reveal the fields, so users see the value. */ export function hasConfiguredNetworkProxy(settings: GlobalSettings): boolean { - return Boolean(settings.httpProxyUrl?.trim() || settings.httpProxyBypassRules?.trim()) + return Boolean( + settings.httpProxyUrl?.trim() || + settings.httpProxyBypassRules?.trim() || + settings.nodeDistMirrorUrl?.trim() + ) } export type HttpProxyUrlDraftState = { @@ -216,7 +221,9 @@ export function AdvancedNetworkSettingsSection({ 'no_proxy', 'network', 'bypass', - 'localhost' + 'localhost', + 'mirror', + 'nodejs' ]} className="space-y-3" > @@ -328,6 +335,11 @@ export function AdvancedNetworkSettingsSection({ )}

+ + updateSettings({ nodeDistMirrorUrl })} + /> diff --git a/src/renderer/src/components/settings/NodeDistMirrorSettingField.tsx b/src/renderer/src/components/settings/NodeDistMirrorSettingField.tsx new file mode 100644 index 00000000000..264ca78285e --- /dev/null +++ b/src/renderer/src/components/settings/NodeDistMirrorSettingField.tsx @@ -0,0 +1,82 @@ +import type React from 'react' +import { useState } from 'react' +import { normalizeNodeDistMirrorUrl } from '../../../../shared/node-dist-mirror' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { translate } from '@/i18n/i18n' + +type MirrorDraft = { source: string; text: string; error: string | null } + +type NodeDistMirrorSettingFieldProps = { + value: string | undefined + onCommit: (value: string) => void +} + +/** Where Orca downloads the Node it installs on SSH and WSL servers, when nodejs.org is out of reach. */ +export function NodeDistMirrorSettingField({ + value, + onCommit +}: NodeDistMirrorSettingFieldProps): React.JSX.Element { + const source = value ?? '' + const [draft, setDraft] = useState({ source, text: source, error: null }) + // Why stored, not derived: a draft left keyed to an old value would match again if it returned. + const current: MirrorDraft = + draft.source === source ? draft : { source, text: source, error: null } + if (current !== draft) { + setDraft(current) + } + + const commit = (): void => { + const normalized = normalizeNodeDistMirrorUrl(current.text) + if (!normalized.ok) { + setDraft({ ...current, error: normalized.message }) + return + } + setDraft({ source, text: normalized.value, error: null }) + if (normalized.value !== source) { + onCommit(normalized.value) + } + } + + return ( +
+ + setDraft({ ...current, text: e.target.value, error: null })} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur() + } + }} + placeholder={translate( + 'auto.components.settings.AdvancedNetworkSettingsSection.nodeDistMirrorPlaceholder', + 'https://mirror.example.com/nodejs/dist' + )} + autoCapitalize="none" + autoCorrect="off" + autoComplete="off" + spellCheck={false} + aria-invalid={current.error ? true : undefined} + className="font-mono text-xs" + /> + {current.error ? ( +

{current.error}

+ ) : ( +

+ {translate( + 'auto.components.settings.AdvancedNetworkSettingsSection.nodeDistMirrorDescription', + 'Optional. A copy of nodejs.org/dist that Orca uses first when it installs Node on SSH and WSL servers. Each file is still checked against the release Orca expects.' + )} +

+ )} +
+ ) +} diff --git a/src/renderer/src/components/settings/advanced-network-search.ts b/src/renderer/src/components/settings/advanced-network-search.ts index fbabdcc7cc8..cd3a00358d7 100644 --- a/src/renderer/src/components/settings/advanced-network-search.ts +++ b/src/renderer/src/components/settings/advanced-network-search.ts @@ -23,7 +23,9 @@ export const getAdvancedNetworkSearchEntries = createLocalizedCatalog((): Settin ...translateSearchKeyword('auto.components.settings.general.search.91a46caafc', 'no_proxy'), ...translateSearchKeyword('auto.components.settings.general.search.3a73054565', 'bypass'), ...translateSearchKeyword('auto.components.settings.general.search.3566fce83f', 'localhost'), - ...translateSearchKeyword('auto.components.settings.general.search.c56cb6f1c2', 'network') + ...translateSearchKeyword('auto.components.settings.general.search.c56cb6f1c2', 'network'), + ...translateSearchKeyword('auto.components.settings.general.search.nodeDistMirror', 'mirror'), + ...translateSearchKeyword('auto.components.settings.general.search.nodeDistNodejs', 'nodejs') ] } ]) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6bb11dfde55..7746e8f10a8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7762,6 +7762,9 @@ "823e0f15b1": "Proxy URL for Orca network requests and local terminal children.", "d93c7cd531": "Configure app-level network routing.", "c46cdbbd4e": "Network", + "nodeDistMirrorLabel": "Node.js Download Mirror", + "nodeDistMirrorPlaceholder": "https://mirror.example.com/nodejs/dist", + "nodeDistMirrorDescription": "Optional. A copy of nodejs.org/dist that Orca uses first when it installs Node on SSH and WSL servers. Each file is still checked against the release Orca expects.", "configureProxy": "Configure proxy" }, "GeneralPane": { @@ -10289,6 +10292,8 @@ "e9d948d3c3": "Save editor and editable diff changes automatically after a short pause.", "ae21e806ce": "Auto Save Files", "c56cb6f1c2": "network", + "nodeDistMirror": "mirror", + "nodeDistNodejs": "nodejs", "3566fce83f": "localhost", "91a46caafc": "no_proxy", "3a73054565": "bypass", diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 5583b8a8ec6..e40cc7bd555 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -124,6 +124,7 @@ export function buildDefaultSettings(args: { terminalScrollbackRows: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT, httpProxyUrl: '', httpProxyBypassRules: '', + nodeDistMirrorUrl: '', electronHttp1CompatibilityMode: false, openLinksInApp: false, localhostWorktreeLabelsEnabled: false, diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 77ea0fbf87d..dfbd1291fd2 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -202,6 +202,8 @@ export type GlobalSettings = GlobalSettingsBase & { httpProxyUrl?: string /** Optional semicolon/comma/newline-separated bypass rules for httpProxyUrl. */ httpProxyBypassRules?: string + /** Optional mirror of nodejs.org/dist for the Node runtime Orca installs on SSH and WSL servers. */ + nodeDistMirrorUrl?: string /** Why: corporate TLS-intercepting proxies can break HTTP/2 downloads; opt-in Chromium process-wide HTTP/1.1 switch. */ electronHttp1CompatibilityMode?: boolean /** Opt-in in-app browsing (isolated guest surface); default keeps links opening in the system browser. */ diff --git a/src/shared/node-dist-mirror.test.ts b/src/shared/node-dist-mirror.test.ts new file mode 100644 index 00000000000..9df2967cbfe --- /dev/null +++ b/src/shared/node-dist-mirror.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { normalizeNodeDistMirrorUrl, resolveNodeDistMirror } from './node-dist-mirror' + +describe('Node dist mirror URL', () => { + it('keeps an http(s) base and drops trailing slashes', () => { + expect(normalizeNodeDistMirrorUrl(' https://npmmirror.com/mirrors/node/ ')).toEqual({ + ok: true, + value: 'https://npmmirror.com/mirrors/node' + }) + expect(normalizeNodeDistMirrorUrl('')).toEqual({ ok: true, value: '' }) + for (const empty of [ + 'https://m.example/dist?', + 'https://m.example/dist#', + 'https://m.example/dist/?#' + ]) { + expect(normalizeNodeDistMirrorUrl(empty)).toEqual({ + ok: true, + value: 'https://m.example/dist' + }) + } + }) + + it.each([ + ['ftp://mirror.example.com/node', 'http or https'], + ['https://user:secret@mirror.example.com/node', 'user name or password'], + ['https://mirror.example.com/node?token=1', 'query'], + ['not a url', 'valid mirror URL'] + ])('refuses %s', (value, message) => { + expect(normalizeNodeDistMirrorUrl(value)).toMatchObject({ + ok: false, + message: expect.stringContaining(message) + }) + }) + + it('prefers the setting, then NODEJS_ORG_MIRROR, then NVM_NODEJS_ORG_MIRROR', () => { + const env = { + NODEJS_ORG_MIRROR: 'https://a.example/dist', + NVM_NODEJS_ORG_MIRROR: 'https://b.example/dist' + } + expect(resolveNodeDistMirror('https://setting.example/dist', env)).toBe( + 'https://setting.example/dist' + ) + expect(resolveNodeDistMirror('', env)).toBe('https://a.example/dist') + expect(resolveNodeDistMirror(undefined, { NVM_NODEJS_ORG_MIRROR: 'https://b.example/' })).toBe( + 'https://b.example' + ) + expect(resolveNodeDistMirror('nonsense', {})).toBeNull() + }) +}) diff --git a/src/shared/node-dist-mirror.ts b/src/shared/node-dist-mirror.ts new file mode 100644 index 00000000000..04c9861e181 --- /dev/null +++ b/src/shared/node-dist-mirror.ts @@ -0,0 +1,60 @@ +/** + * A mirror of nodejs.org/dist (an internal artifact proxy, or a regional mirror) for the pinned + * Node runtime. It only changes where bytes come from: the SHA-256 pin decides what is accepted. + */ + +/** The variables Node tooling already reads for the same mirror; read on this computer only. */ +export const NODE_DIST_MIRROR_ENV_KEYS = ['NODEJS_ORG_MIRROR', 'NVM_NODEJS_ORG_MIRROR'] as const + +export type NodeDistMirrorValidationResult = + | { ok: true; value: string; message?: undefined } + | { ok: false; value: ''; message: string } + +const NODE_DIST_MIRROR_MAX_LENGTH = 2048 + +/** An http(s) base URL with no query or fragment and no trailing slash; empty means none. */ +export function normalizeNodeDistMirrorUrl(value: unknown): NodeDistMirrorValidationResult { + const trimmed = typeof value === 'string' ? value.trim() : '' + if (!trimmed) { + return { ok: true, value: '' } + } + if (trimmed.length > NODE_DIST_MIRROR_MAX_LENGTH) { + return { ok: false, value: '', message: 'Mirror URL is too long.' } + } + let parsed: URL + try { + parsed = new URL(trimmed) + } catch { + return { ok: false, value: '', message: 'Enter a valid mirror URL.' } + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return { ok: false, value: '', message: 'Use an http or https mirror URL.' } + } + // Why: the URL reaches SSH hosts and error text; credentials belong in the proxy setting. + if (parsed.username || parsed.password) { + return { ok: false, value: '', message: 'Mirror URL cannot contain a user name or password.' } + } + if (parsed.search || parsed.hash) { + return { ok: false, value: '', message: 'Mirror URL cannot have a query or fragment.' } + } + // Why origin + pathname: href keeps an empty `?` or `#`, which would swallow the archive path. + return { ok: true, value: `${parsed.origin}${parsed.pathname}`.replace(/\/+$/u, '') } +} + +/** The first valid mirror among Orca's setting and the conventional environment variables. */ +export function resolveNodeDistMirror( + setting: string | undefined, + env: Record +): string | null { + for (const candidate of [setting, ...NODE_DIST_MIRROR_ENV_KEYS.map((key) => env[key])]) { + const normalized = normalizeNodeDistMirrorUrl(candidate) + if (normalized.ok && normalized.value) { + return normalized.value + } + } + return null +} + +export function nodeDistMirrorUrl(mirror: string, version: string, file: string): string { + return `${mirror}/v${version}/${file}` +}