Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions lib/binary/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ const { promisify } = require('util');

const execFileAsync = promisify(cp.execFile);

// repo-intel artifacts grow with history: a mature repo's JSON can exceed 20 MB
// (agnix measured ~21 MB). Node's execFile default maxBuffer is 1 MB, which
// silently fails init/update/query on any real repo with "stdout maxBuffer length
// exceeded". Cap generously; callers can override via options.maxBuffer.
const ANALYZER_MAX_BUFFER = 256 * 1024 * 1024;

const { ANALYZER_MIN_VERSION, BINARY_NAME, GITHUB_REPO } = require('./version');

const PLATFORM_MAP = {
Expand Down Expand Up @@ -957,7 +963,7 @@ function ensureBinarySync(options) {
*/
function runAnalyzer(args, options) {
const binPath = ensureBinarySync();
const opts = Object.assign({ encoding: 'utf8', windowsHide: true }, options);
const opts = Object.assign({ encoding: 'utf8', windowsHide: true, maxBuffer: ANALYZER_MAX_BUFFER }, options);
if (!opts.stdio) opts.stdio = ['pipe', 'pipe', 'pipe'];
const result = cp.execFileSync(binPath, args, opts);
return typeof result === 'string' ? result : result.toString('utf8');
Expand All @@ -971,7 +977,7 @@ function runAnalyzer(args, options) {
*/
async function runAnalyzerAsync(args, options) {
const binPath = await ensureBinary();
const opts = Object.assign({ encoding: 'utf8', windowsHide: true }, options);
const opts = Object.assign({ encoding: 'utf8', windowsHide: true, maxBuffer: ANALYZER_MAX_BUFFER }, options);
const result = await execFileAsync(binPath, args, opts);
return result.stdout;
}
Expand Down
160 changes: 160 additions & 0 deletions lib/binary/shared-helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
'use strict';

/**
* Shared HTTP + archive helpers used by both binary resolvers
* (`lib/binary/index.js` for `agent-analyzer`, `lib/embed/binary.js`
* for `agent-analyzer-embed`).
*
* Extracted to keep the two resolvers from drifting on HTTP redirect
* handling, GitHub auth, and archive extraction details — a single
* fix to e.g. the timeout policy or the redirect cap lands once and
* applies to both binaries.
*
* @module lib/binary/shared-helpers
*/

const fs = require('fs');
const path = require('path');
const os = require('os');
const https = require('https');
const cp = require('child_process');

const DEFAULT_DOWNLOAD_TIMEOUT_MS = 30000;
const MAX_REDIRECTS = 5;

/**
* Fetch a URL into an in-memory Buffer following up to 5 redirects.
*
* Honors `GITHUB_TOKEN` / `GH_TOKEN` for authenticated requests
* (raises rate limit, lets private-repo asset URLs work). Stalled
* connections are killed by the per-request timeout — without this
* a stuck socket would hang the process indefinitely.
*
* @param {string} url
* @param {Object} [options]
* @param {string} [options.userAgent='agent-sh/binary-resolver']
* @param {number} [options.timeoutMs=30000] - per-request timeout
* @returns {Promise<Buffer>}
*/
function downloadToBuffer(url, options) {
const opts = options || {};
const userAgent = opts.userAgent || 'agent-sh/binary-resolver';
const timeoutMs = opts.timeoutMs || DEFAULT_DOWNLOAD_TIMEOUT_MS;

return new Promise(function (resolve, reject) {
const ghToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;

function request(reqUrl, redirectCount) {
if (redirectCount > MAX_REDIRECTS) {
reject(new Error('Too many redirects fetching from ' + url));
return;
}
const headers = {
'User-Agent': userAgent,
'Accept': 'application/octet-stream'
};
if (ghToken) headers['Authorization'] = 'Bearer ' + ghToken;

const req = https.get(reqUrl, { headers: headers, timeout: timeoutMs }, function (res) {
const sc = res.statusCode;
if (sc === 301 || sc === 302 || sc === 307 || sc === 308) {
res.resume();
var loc = res.headers.location;
if (loc && !loc.startsWith('https://')) {
reject(new Error('Refusing non-HTTPS redirect to ' + loc));
return;
}
request(loc, redirectCount + 1);
return;
Comment on lines +62 to +68

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If the Location header is missing or undefined (e.g., due to a malformed redirect response), calling request(loc, ...) will pass undefined as the URL. This causes https.get(undefined, ...) to throw a synchronous TypeError inside the asynchronous response callback, resulting in an unhandled exception that crashes the Node.js process. Adding a check to ensure loc is present before proceeding prevents this crash.

Suggested change
var loc = res.headers.location;
if (loc && !loc.startsWith('https://')) {
reject(new Error('Refusing non-HTTPS redirect to ' + loc));
return;
}
request(loc, redirectCount + 1);
return;
var loc = res.headers.location;
if (!loc) {
reject(new Error('Redirect status ' + sc + ' received but no Location header found'));
return;
}
if (!loc.startsWith('https://')) {
reject(new Error('Refusing non-HTTPS redirect to ' + loc));
return;
}
request(loc, redirectCount + 1);
return;

}
if (sc !== 200) {
res.resume();
const hint = sc === 403 ? ' (rate limited - set GITHUB_TOKEN env var)' : '';
reject(new Error('HTTP ' + sc + hint + ' fetching ' + reqUrl));
return;
}
const chunks = [];
res.on('data', function (chunk) { chunks.push(chunk); });
res.on('end', function () { resolve(Buffer.concat(chunks)); });
res.on('error', reject);
});
req.on('error', reject);
req.on('timeout', function () {
req.destroy();
reject(new Error('Timeout (' + timeoutMs + 'ms) fetching ' + reqUrl));
});
}

request(url, 0);
});
}

/**
* Extract a `.tar.gz` Buffer into `destDir` using the system `tar`.
* Available on Linux, macOS, and Windows (built into recent Win10/11).
*
* @param {Buffer} buf
* @param {string} destDir
* @returns {Promise<void>}
*/
function extractTarGz(buf, destDir) {
return new Promise(function (resolve, reject) {
const tarDest = process.platform === 'win32' ? destDir.replace(/\\/g, '/') : destDir;
const tar = cp.spawn('tar', ['xz', '-C', tarDest], {
stdio: ['pipe', 'pipe', 'pipe']
});
let stderr = '';
tar.stderr.on('data', function (d) { stderr += d; });
tar.stdin.write(buf);
tar.stdin.end();
Comment on lines +103 to +109

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the tar process fails to spawn or exits immediately, writing to tar.stdin can emit an unhandled EPIPE or broken pipe error on the stream. In Node.js, unhandled stream errors crash the process. Registering a dummy error listener on tar.stdin prevents these crashes, as any actual process failures are already handled by the close or error events on the child process itself.

    const tar = cp.spawn('tar', ['xz', '-C', tarDest], {
      stdio: ['pipe', 'pipe', 'pipe']
    });
    tar.stdin.on('error', function () {}); // Prevent unhandled EPIPE crashes
    let stderr = '';
    tar.stderr.on('data', function (d) { stderr += d; });
    tar.stdin.write(buf);
    tar.stdin.end();

tar.on('close', function (code) {
if (code !== 0) {
reject(new Error('tar extraction failed (code ' + code + '): ' + stderr));
} else {
resolve();
}
});
tar.on('error', reject);
});
}

/**
* Extract a `.zip` Buffer into `destDir` using PowerShell's
* `Expand-Archive` (Windows-only).
*
* @param {Buffer} buf
* @param {string} destDir
* @param {string} binaryName - used as the temp-dir prefix
* @returns {Promise<void>}
*/
function extractZip(buf, destDir, binaryName) {
return new Promise(function (resolve, reject) {
var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), binaryName + '-'));
var tmpZip = path.join(tmpDir, 'archive.zip');
fs.writeFileSync(tmpZip, buf);
var ps = cp.spawn(
'powershell',
['-NoProfile', '-NonInteractive', '-Command',
'Expand-Archive', '-Path', tmpZip, '-DestinationPath', destDir, '-Force'],
{ stdio: ['ignore', 'pipe', 'pipe'] }
);
var stderr = '';
ps.stderr.on('data', function (d) { stderr += d; });
ps.on('close', function (code) {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) { /* ignore */ }
if (code !== 0) {
reject(new Error('zip extraction failed (code ' + code + '): ' + stderr));
} else {
resolve();
}
});
ps.on('error', reject);
});
}

module.exports = {
downloadToBuffer,
extractTarGz,
extractZip,
DEFAULT_DOWNLOAD_TIMEOUT_MS
};
171 changes: 171 additions & 0 deletions lib/repo-intel/cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* Repo map cache management
*
* @module lib/repo-intel/cache
*/

'use strict';

const fs = require('fs');
const path = require('path');
const { getStateDirPath } = require('../platform/state-dir');
const { writeJsonAtomic, writeFileAtomic } = require('../utils/atomic-write');

const MAP_FILENAME = 'repo-map.json';
const STALE_FILENAME = 'repo-map.stale';
const INTEL_FILENAME = 'repo-intel.json';

/**
* Get repo-map path (the converted view artifact).
* @param {string} basePath - Repository root
* @returns {string}
*/
function getMapPath(basePath) {
return path.join(getStateDirPath(basePath), MAP_FILENAME);
}

/**
* Get the RAW repo-intel.json path (the binary's native artifact).
* The embed submodule (orchestrator.js) feeds this to the embed binary's
* --map-file. In the standalone layout cache.getPath returned repo-intel.json;
* the fold renamed the converted-view accessor to getMapPath, so getPath keeps
* its original raw-artifact meaning. Mirrors index.js getIntelMapPath.
* @param {string} basePath - Repository root
* @returns {string}
*/
function getPath(basePath) {
return path.join(getStateDirPath(basePath), INTEL_FILENAME);
}

/**
* Get stale marker path
* @param {string} basePath - Repository root
* @returns {string}
*/
function getStalePath(basePath) {
return path.join(getStateDirPath(basePath), STALE_FILENAME);
}

/**
* Ensure state directory exists
* @param {string} basePath - Repository root
* @returns {string}
*/
function ensureStateDir(basePath) {
const stateDir = getStateDirPath(basePath);
if (!fs.existsSync(stateDir)) {
fs.mkdirSync(stateDir, { recursive: true });
}
return stateDir;
}

/**
* Load repo-map from cache
* @param {string} basePath - Repository root
* @returns {Object|null}
*/
function load(basePath) {
const mapPath = getMapPath(basePath);
if (!fs.existsSync(mapPath)) return null;

try {
const raw = fs.readFileSync(mapPath, 'utf8');
return JSON.parse(raw);
} catch {
return null;
}
}

/**
* Save repo-map to cache
* @param {string} basePath - Repository root
* @param {Object} map - Map object
*/
function save(basePath, map) {
ensureStateDir(basePath);
const mapPath = getMapPath(basePath);

const output = {
...map,
updated: new Date().toISOString()
};

writeJsonAtomic(mapPath, output);

// Clear stale marker if present
clearStale(basePath);
}

/**
* Check if repo-map exists
* @param {string} basePath - Repository root
* @returns {boolean}
*/
function exists(basePath) {
return fs.existsSync(getMapPath(basePath));
}

/**
* Mark repo-map as stale
* @param {string} basePath - Repository root
*/
function markStale(basePath) {
ensureStateDir(basePath);
writeFileAtomic(getStalePath(basePath), new Date().toISOString());
}

/**
* Clear stale marker
* @param {string} basePath - Repository root
*/
function clearStale(basePath) {
const stalePath = getStalePath(basePath);
if (fs.existsSync(stalePath)) {
fs.unlinkSync(stalePath);
}
}

/**
* Check if stale marker exists
* @param {string} basePath - Repository root
* @returns {boolean}
*/
function isMarkedStale(basePath) {
return fs.existsSync(getStalePath(basePath));
}

/**
* Get basic status summary
* @param {string} basePath - Repository root
* @returns {Object|null}
*/
function getStatus(basePath) {
const map = load(basePath);
if (!map) return null;

return {
generated: map.generated,
updated: map.updated,
commit: map.git?.commit,
branch: map.git?.branch,
files: Object.keys(map.files || {}).length,
symbols: map.stats?.totalSymbols || 0,
languages: map.project?.languages || []
};
}

module.exports = {
load,
save,
exists,
getStatus,
getMapPath,
// getPath -> raw repo-intel.json (embed orchestrator); getStateDirPath
// re-exported for embed/preference.js. Both delegate to platform/state-dir
// and match the names the standalone cache exposed.
getPath,
getStateDirPath,
markStale,
clearStale,
isMarkedStale
};
Loading
Loading