mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
feat(ssh): let a nodejs.org mirror serve the pinned Node on this computer and the host (#30981)
* feat(ssh): let a nodejs.org mirror serve the pinned Node on both machines * fix(ssh): share one deadline across every host download URL * fix(ssh): give each host download URL its share of the deadline and cap its size * refactor(ssh): one tool per host, labelled sources and a portable wget watchdog * fix(ssh): quote this computer's short download reason in the combined error * fix(ssh): fetch only validated mirror URLs on the host * fix(ssh): drop an empty query or fragment from the mirror URL * fix(ssh): keep a cancel or a local error as it is after the mirror fails
This commit is contained in:
@@ -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<GlobalSettings>,
|
||||
sanitized: Partial<GlobalSettings>
|
||||
): 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string, string>): { 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<string, string>): { 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<string, string>; 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', '<html>blocked by policy</html>') })
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 >/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<string> {
|
||||
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(
|
||||
|
||||
@@ -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<string, string | undefined> = process.env
|
||||
): string | null {
|
||||
return resolveNodeDistMirror(readMirrorSetting?.(), env)
|
||||
}
|
||||
@@ -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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(
|
||||
async () => new Response('<html>blocked</html>', { 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<typeof fetch>(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<typeof fetch>(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)
|
||||
})
|
||||
})
|
||||
@@ -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<T>(
|
||||
target: NodeRuntimeTarget,
|
||||
options: PinnedRuntimeMaterializeOptions,
|
||||
run: (archive: PinnedRuntimeArchive, options: PinnedRuntimeMaterializeOptions) => Promise<T>
|
||||
): Promise<T> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
}
|
||||
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.
|
||||
|
||||
+62
@@ -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(
|
||||
<AdvancedNetworkSettingsSection
|
||||
settings={{ ...getDefaultSettings('/tmp'), nodeDistMirrorUrl: 'https://old.example/dist' }}
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
)
|
||||
const input = container.querySelector<HTMLInputElement>('#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(
|
||||
<AdvancedNetworkSettingsSection
|
||||
settings={{ ...getDefaultSettings('/tmp'), nodeDistMirrorUrl: 'https://old.example/dist' }}
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
)
|
||||
const input = container.querySelector<HTMLInputElement>('#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) => (
|
||||
<AdvancedNetworkSettingsSection
|
||||
settings={{ ...base, nodeDistMirrorUrl: mirror }}
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
)
|
||||
const { container, rerender } = render(view(a))
|
||||
const input = () => container.querySelector<HTMLInputElement>('#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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NodeDistMirrorSettingField
|
||||
value={settings.nodeDistMirrorUrl}
|
||||
onCommit={(nodeDistMirrorUrl) => updateSettings({ nodeDistMirrorUrl })}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
@@ -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<MirrorDraft>({ 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 (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings-node-dist-mirror-url">
|
||||
{translate(
|
||||
'auto.components.settings.AdvancedNetworkSettingsSection.nodeDistMirrorLabel',
|
||||
'Node.js Download Mirror'
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="settings-node-dist-mirror-url"
|
||||
value={current.text}
|
||||
onChange={(e) => 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 ? (
|
||||
<p className="text-xs text-destructive">{current.error}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -124,6 +124,7 @@ export function buildDefaultSettings(args: {
|
||||
terminalScrollbackRows: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT,
|
||||
httpProxyUrl: '',
|
||||
httpProxyBypassRules: '',
|
||||
nodeDistMirrorUrl: '',
|
||||
electronHttp1CompatibilityMode: false,
|
||||
openLinksInApp: false,
|
||||
localhostWorktreeLabelsEnabled: false,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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, string | undefined>
|
||||
): 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}`
|
||||
}
|
||||
Reference in New Issue
Block a user