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 (
+
+
+ {translate(
+ 'auto.components.settings.AdvancedNetworkSettingsSection.nodeDistMirrorLabel',
+ 'Node.js Download Mirror'
+ )}
+
+
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}`
+}