From 8133e57d5777f431f5b7d0bd189482690a77cd30 Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Sun, 12 Apr 2026 14:47:17 -0700 Subject: [PATCH] fix(installer): cap redirect depth and preserve dual-fallback errors Add maxRedirects parameter to fetch() and _fetchWithHeaders() to prevent unbounded redirect recursion. Wrap CDN fallback in try/catch and throw AggregateError with both API and CDN errors for better diagnostics. Extract marketplace repo coordinates into named constants in external-manager. --- tools/installer/modules/external-manager.js | 5 +++- tools/installer/modules/registry-client.js | 29 ++++++++++++++------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/tools/installer/modules/external-manager.js b/tools/installer/modules/external-manager.js index be2199d58..b91d353af 100644 --- a/tools/installer/modules/external-manager.js +++ b/tools/installer/modules/external-manager.js @@ -6,6 +6,9 @@ const yaml = require('yaml'); const prompts = require('../prompts'); const { RegistryClient } = require('./registry-client'); +const MARKETPLACE_OWNER = 'bmad-code-org'; +const MARKETPLACE_REPO = 'bmad-plugins-marketplace'; +const MARKETPLACE_REF = 'main'; const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml'); /** @@ -32,7 +35,7 @@ class ExternalModuleManager { // Try remote registry first try { - const config = await this._client.fetchGitHubYaml('bmad-code-org', 'bmad-plugins-marketplace', 'registry/official.yaml', 'main'); + const config = await this._client.fetchGitHubYaml(MARKETPLACE_OWNER, MARKETPLACE_REPO, 'registry/official.yaml', MARKETPLACE_REF); if (config?.modules?.length) { this.cachedModules = config; return config; diff --git a/tools/installer/modules/registry-client.js b/tools/installer/modules/registry-client.js index df0a10b74..3ad6bd93b 100644 --- a/tools/installer/modules/registry-client.js +++ b/tools/installer/modules/registry-client.js @@ -12,18 +12,22 @@ class RegistryClient { /** * Fetch a URL and return the response body as a string. - * Follows one redirect (GitHub sometimes 301s). + * Follows up to 3 redirects (GitHub sometimes 301s). * @param {string} url - URL to fetch * @param {number} [timeout] - Timeout in ms (overrides default) + * @param {number} [maxRedirects=3] - Maximum redirects to follow * @returns {Promise} Response body */ - fetch(url, timeout) { + fetch(url, timeout, maxRedirects = 3) { const timeoutMs = timeout || this.timeout; return new Promise((resolve, reject) => { const req = https .get(url, { timeout: timeoutMs }, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - return this.fetch(res.headers.location, timeoutMs).then(resolve, reject); + if (maxRedirects <= 0) { + return reject(new Error('Too many redirects')); + } + return this.fetch(res.headers.location, timeoutMs, maxRedirects - 1).then(resolve, reject); } if (res.statusCode !== 200) { return reject(new Error(`HTTP ${res.statusCode}`)); @@ -84,11 +88,14 @@ class RegistryClient { // Try GitHub Contents API first (with raw content accept header) try { return await this._fetchWithHeaders(apiUrl, { Accept: 'application/vnd.github.raw+json' }, timeout); - } catch { + } catch (apiError) { // API failed — fall back to raw CDN + try { + return await this.fetch(rawUrl, timeout); + } catch (cdnError) { + throw new AggregateError([apiError, cdnError], `Both GitHub API and raw CDN failed for ${filePath}`); + } } - - return this.fetch(rawUrl, timeout); } /** @@ -121,14 +128,15 @@ class RegistryClient { /** * Fetch a URL with custom headers. Used for GitHub API requests. - * Follows one redirect. + * Follows up to 3 redirects. * @param {string} url - URL to fetch * @param {Object} headers - Request headers * @param {number} [timeout] - Timeout in ms + * @param {number} [maxRedirects=3] - Maximum redirects to follow * @returns {Promise} Response body * @private */ - _fetchWithHeaders(url, headers, timeout) { + _fetchWithHeaders(url, headers, timeout, maxRedirects = 3) { const timeoutMs = timeout || this.timeout; const parsed = new URL(url); const options = { @@ -145,7 +153,10 @@ class RegistryClient { const req = https .get(options, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - return this._fetchWithHeaders(res.headers.location, headers, timeoutMs).then(resolve, reject); + if (maxRedirects <= 0) { + return reject(new Error('Too many redirects')); + } + return this._fetchWithHeaders(res.headers.location, headers, timeoutMs, maxRedirects - 1).then(resolve, reject); } if (res.statusCode !== 200) { return reject(new Error(`HTTP ${res.statusCode}`));