From 3d34ad07162168995abe4c069bcc7a76381e44ad Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 18 Apr 2026 00:11:44 -0700 Subject: [PATCH 1/7] 10 minutes before cache clears in free mode --- agents/base2/base2.ts | 50 +++++++++++++++++++++++++++------------- agents/context-pruner.ts | 18 +++++++++++++-- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index c20359d14..3bd795626 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -284,22 +284,40 @@ ${PLACEHOLDER.GIT_CHANGES_PROMPT} noAskUser, }), - handleSteps: function* ({ params }) { - while (true) { - // Run context-pruner before each step - yield { - toolName: 'spawn_agent_inline', - input: { - agent_type: 'context-pruner', - params: params ?? {}, - }, - includeToolCall: false, - } as any - - const { stepsComplete } = yield 'STEP' - if (stepsComplete) break - } - }, + // handleSteps is serialized via .toString() and re-eval'd, so closure + // variables like `isFree` are not in scope at runtime. Pick the right + // literal-baked function here instead. + handleSteps: isFree + ? function* ({ params }) { + while (true) { + yield { + toolName: 'spawn_agent_inline', + input: { + agent_type: 'context-pruner', + params: { ...(params ?? {}), cacheExpiryMs: 10 * 60 * 1000 }, + }, + includeToolCall: false, + } as any + + const { stepsComplete } = yield 'STEP' + if (stepsComplete) break + } + } + : function* ({ params }) { + while (true) { + yield { + toolName: 'spawn_agent_inline', + input: { + agent_type: 'context-pruner', + params: params ?? {}, + }, + includeToolCall: false, + } as any + + const { stepsComplete } = yield 'STEP' + if (stepsComplete) break + } + }, } } diff --git a/agents/context-pruner.ts b/agents/context-pruner.ts index fd98630d3..804f3cebb 100644 --- a/agents/context-pruner.ts +++ b/agents/context-pruner.ts @@ -31,6 +31,9 @@ const definition: AgentDefinition = { userBudget: { type: 'number', }, + cacheExpiryMs: { + type: 'number', + }, }, required: [], }, @@ -74,8 +77,8 @@ const definition: AgentDefinition = { /** Fudge factor for token count threshold to trigger pruning earlier */ const TOKEN_COUNT_FUDGE_FACTOR = 1_000 - /** Prompt cache expiry time (Anthropic caches for 5 minutes) */ - const CACHE_EXPIRY_MS = 5 * 60 * 1000 + /** Prompt cache expiry time (Anthropic caches for 5 minutes by default) */ + const CACHE_EXPIRY_MS: number = params?.cacheExpiryMs ?? 5 * 60 * 1000 /** Header used in conversation summaries */ const SUMMARY_HEADER = @@ -328,6 +331,17 @@ const definition: AgentDefinition = { currentMessages.splice(lastSubagentSpawnIndex, 1) } + // Also remove the params USER_PROMPT if params were provided to this agent + // (this is the message like {"cacheExpiryMs": 600000}) + if (params && Object.keys(params).length > 0) { + const lastUserPromptIndex = currentMessages.findLastIndex((message) => + message.tags?.includes('USER_PROMPT'), + ) + if (lastUserPromptIndex !== -1) { + currentMessages.splice(lastUserPromptIndex, 1) + } + } + // Check for prompt cache miss (>5 min gap before the USER_PROMPT message) // The USER_PROMPT is the actual user message; INSTRUCTIONS_PROMPT comes after it // We need to find the USER_PROMPT and check the gap between it and the last assistant message From 4d96066898ae9ad7203d62a1ccacd9aadcb750ad Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 18 Apr 2026 00:13:43 -0700 Subject: [PATCH 2/7] Remove thinker-with-files-gemini from freebuff --- agents/base2/base2.ts | 6 ------ common/src/constants/free-agents.ts | 3 --- common/src/tools/params/tool/spawn-agents.ts | 2 +- 3 files changed, 1 insertion(+), 10 deletions(-) diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index 3bd795626..c4b080d60 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -88,7 +88,6 @@ export function createBase2( isFree && 'code-reviewer-lite', isDefault && 'code-reviewer', isMax && 'code-reviewer-multi-prompt', - isFree && 'thinker-with-files-gemini', 'thinker-gpt', 'context-pruner', ), @@ -144,7 +143,6 @@ Use the spawn_agents tool to spawn specialized agents to help you complete the u ${buildArray( '- Spawn context-gathering agents (file pickers, code searchers, and web/docs researchers) before making edits. Use the list_directory and glob tools directly for searching and exploring the codebase.', isFree && 'Do not spawn the thinker-gpt agent, unless the user asks. Not everyone has connected their ChatGPT subscription to Codebuff to allow for it.', - isFree && `Spawn the thinker-with-files-gemini agent for complex problems — it's very smart. Skip it for routine edits and clearly-scoped changes. Pass the relevant filePaths since it has no conversation history.`, isDefault && '- Spawn the editor agent to implement the changes after you have gathered all the context you need.', (isDefault || isMax) && @@ -354,8 +352,6 @@ ${buildArray( 'After getting context on the user request from the codebase or from research, use the ask_user tool to ask the user for important clarifications on their request or alternate implementation strategies. You should skip this step if the choice is obvious -- only ask the user if you need their help making the best choice.', (isDefault || isMax || isFree) && `- For any task requiring 3+ steps, use the write_todos tool to write out your step-by-step implementation plan. Include ALL of the applicable tasks in the list.${isFast ? '' : ' You should include a step to review the changes after you have implemented the changes.'}:${hasNoValidation ? '' : ' You should include at least one step to validate/test your changes: be specific about whether to typecheck, run tests, run lints, etc.'} You may be able to do reviewing and validation in parallel in the same step. Skip write_todos for simple tasks like quick edits or answering questions.`, - isFree && - `- For complex problems, spawn the thinker-with-files-gemini agent after gathering context. Skip it for routine edits and clearly-scoped changes. Pass the relevant filePaths.`, (isDefault || isMax) && `- For quick problems, briefly explain your reasoning to the user. If you need to think longer, write your thoughts within the tags. Finally, for complex problems, spawn the thinker agent to help find the best solution. (gpt-5-agent is a last resort for complex problems)`, isDefault && @@ -400,8 +396,6 @@ function buildImplementationStepPrompt({ isMax && `Keep working until the user's request is completely satisfied${!hasNoValidation ? ' and validated' : ''}, or until you require more information from the user.`, 'Consider loading relevant skills with the skill tool if they might help with the current task. Do not reload skills that were already loaded earlier in this conversation.', - isFree && - `Spawn the thinker-with-files-gemini agent for complex problems, not routine edits. Pass the relevant filePaths.`, isMax && `You must spawn the 'editor-multi-prompt' agent to implement code changes rather than using the str_replace or write_file tools, since it will generate the best code changes.`, (isDefault || isMax) && diff --git a/common/src/constants/free-agents.ts b/common/src/constants/free-agents.ts index 551500f3f..c285ba7c8 100644 --- a/common/src/constants/free-agents.ts +++ b/common/src/constants/free-agents.ts @@ -37,9 +37,6 @@ export const FREE_MODE_AGENT_MODELS: Record> = { // Code reviewer for free mode 'code-reviewer-lite': new Set(['minimax/minimax-m2.7', 'z-ai/glm-5.1']), - - // Thinker for free mode - 'thinker-with-files-gemini': new Set(['google/gemini-3.1-pro-preview']), } /** diff --git a/common/src/tools/params/tool/spawn-agents.ts b/common/src/tools/params/tool/spawn-agents.ts index fe88beaa0..0ba3e9268 100644 --- a/common/src/tools/params/tool/spawn-agents.ts +++ b/common/src/tools/params/tool/spawn-agents.ts @@ -34,7 +34,7 @@ const inputSchema = z cwd: z.string().optional().describe('Optional working directory relative to project root'), maxResults: z.number().optional().describe('Max results per file. Default 15'), })).optional().describe('Array of code search queries (code-searcher)'), - filePaths: z.array(z.string()).optional().describe('Relevant file paths to read (opus-agent, gpt-5-agent, thinker-with-files-gemini)'), + filePaths: z.array(z.string()).optional().describe('Relevant file paths to read (opus-agent, gpt-5-agent)'), directories: z.array(z.string()).optional().describe('Directories to search within (file-picker)'), url: z.string().optional().describe('Starting URL to navigate to (browser-use)'), prompts: z.array(z.string()).optional().describe('Array of strategy prompts (editor-multi-prompt, code-reviewer-multi-prompt)'), From 84ff5336f994eece33609d32e86c3b9fc658cffc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 18 Apr 2026 07:18:42 +0000 Subject: [PATCH 3/7] Bump version to 1.0.642 --- cli/release/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/release/package.json b/cli/release/package.json index 1eb51b176..efd515670 100644 --- a/cli/release/package.json +++ b/cli/release/package.json @@ -1,6 +1,6 @@ { "name": "codebuff", - "version": "1.0.641", + "version": "1.0.642", "description": "AI coding agent", "license": "MIT", "bin": { From b24d69f72eac7a92e315d73f72de064946cb3f49 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 18 Apr 2026 07:18:53 +0000 Subject: [PATCH 4/7] Bump Freebuff version to 0.0.34 --- freebuff/cli/release/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freebuff/cli/release/package.json b/freebuff/cli/release/package.json index dc00bf86c..50a6b6b39 100644 --- a/freebuff/cli/release/package.json +++ b/freebuff/cli/release/package.json @@ -1,6 +1,6 @@ { "name": "freebuff", - "version": "0.0.33", + "version": "0.0.34", "description": "The world's strongest free coding agent", "license": "MIT", "bin": { From 4b6851d42050242a09949a08b26a9f7f86586055 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 18 Apr 2026 00:23:09 -0700 Subject: [PATCH 5/7] Increase test timeout --- .../api/v1/chat/completions/__tests__/completions.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts index ea74ad256..e503f4c7c 100644 --- a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts +++ b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts @@ -785,6 +785,10 @@ describe('/api/v1/chat/completions POST endpoint', () => { }) describe('Subscription limit enforcement', () => { + // Bumped from Bun's 5s default: the non-streaming fetch-path tests here + // have flaked right at the boundary (observed 5001ms) on loaded machines. + const SUBSCRIPTION_TEST_TIMEOUT_MS = 15000 + const createValidRequest = () => new NextRequest('http://localhost:3000/api/v1/chat/completions', { method: 'POST', @@ -1023,7 +1027,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { expect(response.status).toBe(200) // getUserPreferences should not be called for non-subscribers expect(mockGetUserPreferences).not.toHaveBeenCalled() - }) + }, SUBSCRIPTION_TEST_TIMEOUT_MS) it('defaults to allowing fallback when getUserPreferences is not provided', async () => { const weeklyLimitError: BlockGrantResult = { @@ -1050,7 +1054,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { // Should continue processing (default to allowing a-la-carte) expect(response.status).toBe(200) - }) + }, SUBSCRIPTION_TEST_TIMEOUT_MS) it('allows subscriber with 0 a-la-carte credits but active block grant', async () => { const blockGrant: BlockGrantResult = { From 93959cbdb736865b7d0d9aea5afde574ec2a4ab0 Mon Sep 17 00:00:00 2001 From: Shangxin Date: Sat, 18 Apr 2026 15:25:48 +0800 Subject: [PATCH 6/7] fix: avoid DNS lookup after proxied release CONNECT (#506) --- cli/release-staging/http.js | 176 +++++++++++++ cli/release-staging/index.js | 125 +-------- cli/release-staging/package.json | 1 + cli/release/http.js | 176 +++++++++++++ cli/release/index.js | 125 +-------- cli/release/package.json | 1 + .../__tests__/release/proxy-http-get.test.ts | 237 ++++++++++++++++++ freebuff/cli/release/http.js | 176 +++++++++++++ freebuff/cli/release/index.js | 125 +-------- freebuff/cli/release/package.json | 1 + 10 files changed, 786 insertions(+), 357 deletions(-) create mode 100644 cli/release-staging/http.js create mode 100644 cli/release/http.js create mode 100644 cli/src/__tests__/release/proxy-http-get.test.ts create mode 100644 freebuff/cli/release/http.js diff --git a/cli/release-staging/http.js b/cli/release-staging/http.js new file mode 100644 index 000000000..3419e80ca --- /dev/null +++ b/cli/release-staging/http.js @@ -0,0 +1,176 @@ +const http = require('http') +const https = require('https') +const tls = require('tls') + +function createReleaseHttpClient({ + env = process.env, + userAgent, + requestTimeout, + httpModule = http, + httpsModule = https, + tlsModule = tls, +}) { + function getProxyUrl() { + return ( + env.HTTPS_PROXY || + env.https_proxy || + env.HTTP_PROXY || + env.http_proxy || + null + ) + } + + function shouldBypassProxy(hostname) { + const noProxy = env.NO_PROXY || env.no_proxy || '' + if (!noProxy) return false + + const domains = noProxy + .split(',') + .map((domain) => domain.trim().toLowerCase().replace(/:\d+$/, '')) + const host = hostname.toLowerCase() + + return domains.some((domain) => { + if (domain === '*') return true + if (domain.startsWith('.')) { + return host.endsWith(domain) || host === domain.slice(1) + } + return host === domain || host.endsWith(`.${domain}`) + }) + } + + function connectThroughProxy(proxyUrl, targetHost, targetPort) { + return new Promise((resolve, reject) => { + const proxy = new URL(proxyUrl) + const isHttpsProxy = proxy.protocol === 'https:' + const connectOptions = { + hostname: proxy.hostname, + port: proxy.port || (isHttpsProxy ? 443 : 80), + method: 'CONNECT', + path: `${targetHost}:${targetPort}`, + headers: { + Host: `${targetHost}:${targetPort}`, + }, + } + + if (proxy.username || proxy.password) { + const auth = Buffer.from( + `${decodeURIComponent(proxy.username || '')}:${decodeURIComponent( + proxy.password || '', + )}`, + ).toString('base64') + connectOptions.headers['Proxy-Authorization'] = `Basic ${auth}` + } + + const transport = isHttpsProxy ? httpsModule : httpModule + const req = transport.request(connectOptions) + + req.on('connect', (res, socket) => { + if (res.statusCode === 200) { + resolve(socket) + return + } + + socket.destroy() + reject(new Error(`Proxy CONNECT failed with status ${res.statusCode}`)) + }) + + req.on('error', (error) => { + reject(new Error(`Proxy connection failed: ${error.message}`)) + }) + + req.setTimeout(requestTimeout, () => { + req.destroy() + reject(new Error('Proxy connection timeout.')) + }) + + req.end() + }) + } + + async function buildRequestOptions(url, options = {}) { + const parsedUrl = new URL(url) + const reqOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || 443, + path: parsedUrl.pathname + parsedUrl.search, + headers: { + 'User-Agent': userAgent, + ...options.headers, + }, + } + + const proxyUrl = getProxyUrl() + if (!proxyUrl || shouldBypassProxy(parsedUrl.hostname)) { + return reqOptions + } + + const tunnelSocket = await connectThroughProxy( + proxyUrl, + parsedUrl.hostname, + parsedUrl.port || 443, + ) + + class TunnelAgent extends httpsModule.Agent { + createConnection(_options, callback) { + const secureSocket = tlsModule.connect({ + socket: tunnelSocket, + servername: parsedUrl.hostname, + }) + + if (typeof callback === 'function') { + if (typeof secureSocket.once === 'function') { + let settled = false + const finish = (error) => { + if (settled) return + settled = true + callback(error || null, error ? undefined : secureSocket) + } + + secureSocket.once('secureConnect', () => finish(null)) + secureSocket.once('error', (error) => finish(error)) + } else { + callback(null, secureSocket) + } + } + + return secureSocket + } + } + + reqOptions.agent = new TunnelAgent({ keepAlive: false }) + return reqOptions + } + + async function httpGet(url, options = {}) { + const reqOptions = await buildRequestOptions(url, options) + + return new Promise((resolve, reject) => { + const req = httpsModule.get(reqOptions, (res) => { + if (res.statusCode === 301 || res.statusCode === 302) { + res.resume() + httpGet(new URL(res.headers.location, url).href, options) + .then(resolve) + .catch(reject) + return + } + + resolve(res) + }) + + req.on('error', reject) + req.setTimeout(options.timeout || requestTimeout, () => { + req.destroy() + reject(new Error('Request timeout.')) + }) + }) + } + + return { + getProxyUrl, + httpGet, + } +} + +module.exports = { + createReleaseHttpClient, +} diff --git a/cli/release-staging/index.js b/cli/release-staging/index.js index 14f229fb4..083e8879a 100644 --- a/cli/release-staging/index.js +++ b/cli/release-staging/index.js @@ -6,10 +6,10 @@ const http = require('http') const https = require('https') const os = require('os') const path = require('path') -const tls = require('tls') const zlib = require('zlib') const tar = require('tar') +const { createReleaseHttpClient } = require('./http') const packageName = 'codecane' @@ -66,6 +66,11 @@ function createConfig(packageName) { } const CONFIG = createConfig(packageName) +const { getProxyUrl, httpGet } = createReleaseHttpClient({ + env: process.env, + userAgent: CONFIG.userAgent, + requestTimeout: CONFIG.requestTimeout, +}) function getPostHogConfig() { const apiKey = @@ -131,76 +136,6 @@ function trackUpdateFailed(errorMessage, version, context = {}) { } } -function getProxyUrl() { - return ( - process.env.HTTPS_PROXY || - process.env.https_proxy || - process.env.HTTP_PROXY || - process.env.http_proxy || - null - ) -} - -function shouldBypassProxy(hostname) { - const noProxy = process.env.NO_PROXY || process.env.no_proxy || '' - if (!noProxy) return false - const domains = noProxy.split(',').map((d) => d.trim().toLowerCase().replace(/:\d+$/, '')) - const host = hostname.toLowerCase() - return domains.some((d) => { - if (d === '*') return true - if (d.startsWith('.')) return host.endsWith(d) || host === d.slice(1) - return host === d || host.endsWith('.' + d) - }) -} - -function connectThroughProxy(proxyUrl, targetHost, targetPort) { - return new Promise((resolve, reject) => { - const proxy = new URL(proxyUrl) - const isHttpsProxy = proxy.protocol === 'https:' - const connectOptions = { - hostname: proxy.hostname, - port: proxy.port || (isHttpsProxy ? 443 : 80), - method: 'CONNECT', - path: `${targetHost}:${targetPort}`, - headers: { - Host: `${targetHost}:${targetPort}`, - }, - } - - if (proxy.username || proxy.password) { - const auth = Buffer.from( - `${decodeURIComponent(proxy.username || '')}:${decodeURIComponent(proxy.password || '')}`, - ).toString('base64') - connectOptions.headers['Proxy-Authorization'] = `Basic ${auth}` - } - - const transport = isHttpsProxy ? https : http - const req = transport.request(connectOptions) - - req.on('connect', (res, socket) => { - if (res.statusCode === 200) { - resolve(socket) - } else { - socket.destroy() - reject( - new Error(`Proxy CONNECT failed with status ${res.statusCode}`), - ) - } - }) - - req.on('error', (err) => { - reject(new Error(`Proxy connection failed: ${err.message}`)) - }) - - req.setTimeout(CONFIG.requestTimeout, () => { - req.destroy() - reject(new Error('Proxy connection timeout.')) - }) - - req.end() - }) -} - const PLATFORM_TARGETS = { 'linux-x64': `${packageName}-linux-x64.tar.gz`, 'linux-arm64': `${packageName}-linux-arm64.tar.gz`, @@ -225,54 +160,6 @@ const term = { }, } -async function httpGet(url, options = {}) { - const parsedUrl = new URL(url) - const proxyUrl = getProxyUrl() - - const reqOptions = { - hostname: parsedUrl.hostname, - path: parsedUrl.pathname + parsedUrl.search, - headers: { - 'User-Agent': CONFIG.userAgent, - ...options.headers, - }, - } - - if (proxyUrl && !shouldBypassProxy(parsedUrl.hostname)) { - const tunnelSocket = await connectThroughProxy( - proxyUrl, - parsedUrl.hostname, - parsedUrl.port || 443, - ) - reqOptions.agent = false - reqOptions.createConnection = () => - tls.connect({ - socket: tunnelSocket, - servername: parsedUrl.hostname, - }) - } - - return new Promise((resolve, reject) => { - const req = https.get(reqOptions, (res) => { - if (res.statusCode === 302 || res.statusCode === 301) { - res.resume() - return httpGet(new URL(res.headers.location, url).href, options) - .then(resolve) - .catch(reject) - } - resolve(res) - }) - - req.on('error', reject) - - const timeout = options.timeout || CONFIG.requestTimeout - req.setTimeout(timeout, () => { - req.destroy() - reject(new Error('Request timeout.')) - }) - }) -} - async function getLatestVersion() { try { const res = await httpGet( diff --git a/cli/release-staging/package.json b/cli/release-staging/package.json index 23ae8cac3..f84bff872 100644 --- a/cli/release-staging/package.json +++ b/cli/release-staging/package.json @@ -12,6 +12,7 @@ }, "files": [ "index.js", + "http.js", "postinstall.js", "README.md" ], diff --git a/cli/release/http.js b/cli/release/http.js new file mode 100644 index 000000000..3419e80ca --- /dev/null +++ b/cli/release/http.js @@ -0,0 +1,176 @@ +const http = require('http') +const https = require('https') +const tls = require('tls') + +function createReleaseHttpClient({ + env = process.env, + userAgent, + requestTimeout, + httpModule = http, + httpsModule = https, + tlsModule = tls, +}) { + function getProxyUrl() { + return ( + env.HTTPS_PROXY || + env.https_proxy || + env.HTTP_PROXY || + env.http_proxy || + null + ) + } + + function shouldBypassProxy(hostname) { + const noProxy = env.NO_PROXY || env.no_proxy || '' + if (!noProxy) return false + + const domains = noProxy + .split(',') + .map((domain) => domain.trim().toLowerCase().replace(/:\d+$/, '')) + const host = hostname.toLowerCase() + + return domains.some((domain) => { + if (domain === '*') return true + if (domain.startsWith('.')) { + return host.endsWith(domain) || host === domain.slice(1) + } + return host === domain || host.endsWith(`.${domain}`) + }) + } + + function connectThroughProxy(proxyUrl, targetHost, targetPort) { + return new Promise((resolve, reject) => { + const proxy = new URL(proxyUrl) + const isHttpsProxy = proxy.protocol === 'https:' + const connectOptions = { + hostname: proxy.hostname, + port: proxy.port || (isHttpsProxy ? 443 : 80), + method: 'CONNECT', + path: `${targetHost}:${targetPort}`, + headers: { + Host: `${targetHost}:${targetPort}`, + }, + } + + if (proxy.username || proxy.password) { + const auth = Buffer.from( + `${decodeURIComponent(proxy.username || '')}:${decodeURIComponent( + proxy.password || '', + )}`, + ).toString('base64') + connectOptions.headers['Proxy-Authorization'] = `Basic ${auth}` + } + + const transport = isHttpsProxy ? httpsModule : httpModule + const req = transport.request(connectOptions) + + req.on('connect', (res, socket) => { + if (res.statusCode === 200) { + resolve(socket) + return + } + + socket.destroy() + reject(new Error(`Proxy CONNECT failed with status ${res.statusCode}`)) + }) + + req.on('error', (error) => { + reject(new Error(`Proxy connection failed: ${error.message}`)) + }) + + req.setTimeout(requestTimeout, () => { + req.destroy() + reject(new Error('Proxy connection timeout.')) + }) + + req.end() + }) + } + + async function buildRequestOptions(url, options = {}) { + const parsedUrl = new URL(url) + const reqOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || 443, + path: parsedUrl.pathname + parsedUrl.search, + headers: { + 'User-Agent': userAgent, + ...options.headers, + }, + } + + const proxyUrl = getProxyUrl() + if (!proxyUrl || shouldBypassProxy(parsedUrl.hostname)) { + return reqOptions + } + + const tunnelSocket = await connectThroughProxy( + proxyUrl, + parsedUrl.hostname, + parsedUrl.port || 443, + ) + + class TunnelAgent extends httpsModule.Agent { + createConnection(_options, callback) { + const secureSocket = tlsModule.connect({ + socket: tunnelSocket, + servername: parsedUrl.hostname, + }) + + if (typeof callback === 'function') { + if (typeof secureSocket.once === 'function') { + let settled = false + const finish = (error) => { + if (settled) return + settled = true + callback(error || null, error ? undefined : secureSocket) + } + + secureSocket.once('secureConnect', () => finish(null)) + secureSocket.once('error', (error) => finish(error)) + } else { + callback(null, secureSocket) + } + } + + return secureSocket + } + } + + reqOptions.agent = new TunnelAgent({ keepAlive: false }) + return reqOptions + } + + async function httpGet(url, options = {}) { + const reqOptions = await buildRequestOptions(url, options) + + return new Promise((resolve, reject) => { + const req = httpsModule.get(reqOptions, (res) => { + if (res.statusCode === 301 || res.statusCode === 302) { + res.resume() + httpGet(new URL(res.headers.location, url).href, options) + .then(resolve) + .catch(reject) + return + } + + resolve(res) + }) + + req.on('error', reject) + req.setTimeout(options.timeout || requestTimeout, () => { + req.destroy() + reject(new Error('Request timeout.')) + }) + }) + } + + return { + getProxyUrl, + httpGet, + } +} + +module.exports = { + createReleaseHttpClient, +} diff --git a/cli/release/index.js b/cli/release/index.js index 3d22e6573..85c60ff39 100644 --- a/cli/release/index.js +++ b/cli/release/index.js @@ -6,10 +6,10 @@ const http = require('http') const https = require('https') const os = require('os') const path = require('path') -const tls = require('tls') const zlib = require('zlib') const tar = require('tar') +const { createReleaseHttpClient } = require('./http') const packageName = 'codebuff' @@ -66,6 +66,11 @@ function createConfig(packageName) { } const CONFIG = createConfig(packageName) +const { getProxyUrl, httpGet } = createReleaseHttpClient({ + env: process.env, + userAgent: CONFIG.userAgent, + requestTimeout: CONFIG.requestTimeout, +}) function getPostHogConfig() { const apiKey = @@ -130,76 +135,6 @@ function trackUpdateFailed(errorMessage, version, context = {}) { } } -function getProxyUrl() { - return ( - process.env.HTTPS_PROXY || - process.env.https_proxy || - process.env.HTTP_PROXY || - process.env.http_proxy || - null - ) -} - -function shouldBypassProxy(hostname) { - const noProxy = process.env.NO_PROXY || process.env.no_proxy || '' - if (!noProxy) return false - const domains = noProxy.split(',').map((d) => d.trim().toLowerCase().replace(/:\d+$/, '')) - const host = hostname.toLowerCase() - return domains.some((d) => { - if (d === '*') return true - if (d.startsWith('.')) return host.endsWith(d) || host === d.slice(1) - return host === d || host.endsWith('.' + d) - }) -} - -function connectThroughProxy(proxyUrl, targetHost, targetPort) { - return new Promise((resolve, reject) => { - const proxy = new URL(proxyUrl) - const isHttpsProxy = proxy.protocol === 'https:' - const connectOptions = { - hostname: proxy.hostname, - port: proxy.port || (isHttpsProxy ? 443 : 80), - method: 'CONNECT', - path: `${targetHost}:${targetPort}`, - headers: { - Host: `${targetHost}:${targetPort}`, - }, - } - - if (proxy.username || proxy.password) { - const auth = Buffer.from( - `${decodeURIComponent(proxy.username || '')}:${decodeURIComponent(proxy.password || '')}`, - ).toString('base64') - connectOptions.headers['Proxy-Authorization'] = `Basic ${auth}` - } - - const transport = isHttpsProxy ? https : http - const req = transport.request(connectOptions) - - req.on('connect', (res, socket) => { - if (res.statusCode === 200) { - resolve(socket) - } else { - socket.destroy() - reject( - new Error(`Proxy CONNECT failed with status ${res.statusCode}`), - ) - } - }) - - req.on('error', (err) => { - reject(new Error(`Proxy connection failed: ${err.message}`)) - }) - - req.setTimeout(CONFIG.requestTimeout, () => { - req.destroy() - reject(new Error('Proxy connection timeout.')) - }) - - req.end() - }) -} - const PLATFORM_TARGETS = { 'linux-x64': `${packageName}-linux-x64.tar.gz`, 'linux-arm64': `${packageName}-linux-arm64.tar.gz`, @@ -224,54 +159,6 @@ const term = { }, } -async function httpGet(url, options = {}) { - const parsedUrl = new URL(url) - const proxyUrl = getProxyUrl() - - const reqOptions = { - hostname: parsedUrl.hostname, - path: parsedUrl.pathname + parsedUrl.search, - headers: { - 'User-Agent': CONFIG.userAgent, - ...options.headers, - }, - } - - if (proxyUrl && !shouldBypassProxy(parsedUrl.hostname)) { - const tunnelSocket = await connectThroughProxy( - proxyUrl, - parsedUrl.hostname, - parsedUrl.port || 443, - ) - reqOptions.agent = false - reqOptions.createConnection = () => - tls.connect({ - socket: tunnelSocket, - servername: parsedUrl.hostname, - }) - } - - return new Promise((resolve, reject) => { - const req = https.get(reqOptions, (res) => { - if (res.statusCode === 302 || res.statusCode === 301) { - res.resume() - return httpGet(new URL(res.headers.location, url).href, options) - .then(resolve) - .catch(reject) - } - resolve(res) - }) - - req.on('error', reject) - - const timeout = options.timeout || CONFIG.requestTimeout - req.setTimeout(timeout, () => { - req.destroy() - reject(new Error('Request timeout.')) - }) - }) -} - async function getLatestVersion() { try { const res = await httpGet( diff --git a/cli/release/package.json b/cli/release/package.json index efd515670..a839a93a5 100644 --- a/cli/release/package.json +++ b/cli/release/package.json @@ -13,6 +13,7 @@ }, "files": [ "index.js", + "http.js", "postinstall.js", "README.md" ], diff --git a/cli/src/__tests__/release/proxy-http-get.test.ts b/cli/src/__tests__/release/proxy-http-get.test.ts new file mode 100644 index 000000000..a0addd586 --- /dev/null +++ b/cli/src/__tests__/release/proxy-http-get.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, test } from 'bun:test' +import { EventEmitter } from 'node:events' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { Readable } from 'node:stream' + +const require = createRequire(import.meta.url) + +const helperModules = [ + { + name: 'codebuff release helper', + path: fileURLToPath(new URL('../../../release/http.js', import.meta.url)), + }, + { + name: 'codebuff staging release helper', + path: fileURLToPath( + new URL('../../../release-staging/http.js', import.meta.url), + ), + }, + { + name: 'freebuff release helper', + path: fileURLToPath( + new URL('../../../../freebuff/cli/release/http.js', import.meta.url), + ), + }, +] + +function createResponse(statusCode: number, headers: Record, body = '') { + const response = Readable.from(body.length > 0 ? [body] : []) + return Object.assign(response, { + statusCode, + headers, + }) +} + +function createConnectRequest({ + statusCode = 200, + tunnelSocket, + recorder, +}: { + statusCode?: number + tunnelSocket: object + recorder: { timeoutCalls: number } +}) { + const emitter = new EventEmitter() + + return { + on(event: string, listener: (...args: any[]) => void) { + emitter.on(event, listener) + return this + }, + setTimeout() { + recorder.timeoutCalls += 1 + return this + }, + destroy() {}, + end() { + queueMicrotask(() => { + emitter.emit('connect', { statusCode }, tunnelSocket) + }) + }, + } +} + +for (const helperModule of helperModules) { + describe(helperModule.name, () => { + test('uses a tunnel agent instead of createConnection for proxied HTTPS requests', async () => { + const connectCalls: Array> = [] + const httpsGetCalls: Array> = [] + const tlsConnectCalls: Array> = [] + + const tunnelSocket = { kind: 'tunnel-socket' } + const tlsSocket = { kind: 'tls-socket' } + + const { createReleaseHttpClient } = require(helperModule.path) + + const client = createReleaseHttpClient({ + env: { + HTTPS_PROXY: 'http://proxy.internal:7890', + }, + userAgent: 'release-test-agent', + requestTimeout: 2500, + httpModule: { + request(options: Record) { + connectCalls.push(options) + return createConnectRequest({ + tunnelSocket, + recorder: { timeoutCalls: 0 }, + }) + }, + }, + httpsModule: { + Agent: class FakeAgent { + options: Record + + constructor(options: Record) { + this.options = options + } + }, + get(options: Record, callback: (response: Readable) => void) { + httpsGetCalls.push(options) + options.agent.createConnection(options) + queueMicrotask(() => { + callback(createResponse(200, {}, '{"version":"0.0.33"}')) + }) + return { + on() { + return this + }, + setTimeout() { + return this + }, + destroy() {}, + } + }, + }, + tlsModule: { + connect(options: Record) { + tlsConnectCalls.push(options) + return tlsSocket + }, + }, + }) + + const response = await client.httpGet( + 'https://registry.npmjs.org/freebuff/latest', + ) + response.resume() + + expect(connectCalls).toHaveLength(1) + expect(connectCalls[0]).toMatchObject({ + hostname: 'proxy.internal', + port: '7890', + method: 'CONNECT', + path: 'registry.npmjs.org:443', + headers: { + Host: 'registry.npmjs.org:443', + }, + }) + + expect(httpsGetCalls).toHaveLength(1) + expect(httpsGetCalls[0]?.createConnection).toBeUndefined() + expect(httpsGetCalls[0]?.agent).toBeDefined() + expect(httpsGetCalls[0]).toMatchObject({ + hostname: 'registry.npmjs.org', + path: '/freebuff/latest', + headers: { + 'User-Agent': 'release-test-agent', + }, + }) + + expect(tlsConnectCalls).toEqual([ + { + socket: tunnelSocket, + servername: 'registry.npmjs.org', + }, + ]) + }) + + test('reuses the same proxy strategy across redirects', async () => { + const httpsGetCalls: Array> = [] + + const { createReleaseHttpClient } = require(helperModule.path) + + let callCount = 0 + const client = createReleaseHttpClient({ + env: { + HTTPS_PROXY: 'http://proxy.internal:7890', + }, + userAgent: 'release-test-agent', + requestTimeout: 2500, + httpModule: { + request() { + return createConnectRequest({ + tunnelSocket: { kind: 'tunnel-socket' }, + recorder: { timeoutCalls: 0 }, + }) + }, + }, + httpsModule: { + Agent: class FakeAgent {}, + get(options: Record, callback: (response: Readable) => void) { + httpsGetCalls.push(options) + callCount += 1 + + queueMicrotask(() => { + if (callCount === 1) { + callback( + createResponse(302, { + location: '/redirected', + }), + ) + return + } + + callback(createResponse(200, {}, 'ok')) + }) + + return { + on() { + return this + }, + setTimeout() { + return this + }, + destroy() {}, + } + }, + }, + tlsModule: { + connect() { + return { kind: 'tls-socket' } + }, + }, + }) + + const response = await client.httpGet( + 'https://registry.npmjs.org/freebuff/latest', + ) + response.resume() + + expect(httpsGetCalls).toHaveLength(2) + expect(httpsGetCalls[0]).toMatchObject({ + hostname: 'registry.npmjs.org', + path: '/freebuff/latest', + }) + expect(httpsGetCalls[1]).toMatchObject({ + hostname: 'registry.npmjs.org', + path: '/redirected', + }) + expect(httpsGetCalls.every((call) => call.createConnection === undefined)).toBe( + true, + ) + expect(httpsGetCalls.every((call) => call.agent != null)).toBe(true) + }) + }) +} diff --git a/freebuff/cli/release/http.js b/freebuff/cli/release/http.js new file mode 100644 index 000000000..3419e80ca --- /dev/null +++ b/freebuff/cli/release/http.js @@ -0,0 +1,176 @@ +const http = require('http') +const https = require('https') +const tls = require('tls') + +function createReleaseHttpClient({ + env = process.env, + userAgent, + requestTimeout, + httpModule = http, + httpsModule = https, + tlsModule = tls, +}) { + function getProxyUrl() { + return ( + env.HTTPS_PROXY || + env.https_proxy || + env.HTTP_PROXY || + env.http_proxy || + null + ) + } + + function shouldBypassProxy(hostname) { + const noProxy = env.NO_PROXY || env.no_proxy || '' + if (!noProxy) return false + + const domains = noProxy + .split(',') + .map((domain) => domain.trim().toLowerCase().replace(/:\d+$/, '')) + const host = hostname.toLowerCase() + + return domains.some((domain) => { + if (domain === '*') return true + if (domain.startsWith('.')) { + return host.endsWith(domain) || host === domain.slice(1) + } + return host === domain || host.endsWith(`.${domain}`) + }) + } + + function connectThroughProxy(proxyUrl, targetHost, targetPort) { + return new Promise((resolve, reject) => { + const proxy = new URL(proxyUrl) + const isHttpsProxy = proxy.protocol === 'https:' + const connectOptions = { + hostname: proxy.hostname, + port: proxy.port || (isHttpsProxy ? 443 : 80), + method: 'CONNECT', + path: `${targetHost}:${targetPort}`, + headers: { + Host: `${targetHost}:${targetPort}`, + }, + } + + if (proxy.username || proxy.password) { + const auth = Buffer.from( + `${decodeURIComponent(proxy.username || '')}:${decodeURIComponent( + proxy.password || '', + )}`, + ).toString('base64') + connectOptions.headers['Proxy-Authorization'] = `Basic ${auth}` + } + + const transport = isHttpsProxy ? httpsModule : httpModule + const req = transport.request(connectOptions) + + req.on('connect', (res, socket) => { + if (res.statusCode === 200) { + resolve(socket) + return + } + + socket.destroy() + reject(new Error(`Proxy CONNECT failed with status ${res.statusCode}`)) + }) + + req.on('error', (error) => { + reject(new Error(`Proxy connection failed: ${error.message}`)) + }) + + req.setTimeout(requestTimeout, () => { + req.destroy() + reject(new Error('Proxy connection timeout.')) + }) + + req.end() + }) + } + + async function buildRequestOptions(url, options = {}) { + const parsedUrl = new URL(url) + const reqOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || 443, + path: parsedUrl.pathname + parsedUrl.search, + headers: { + 'User-Agent': userAgent, + ...options.headers, + }, + } + + const proxyUrl = getProxyUrl() + if (!proxyUrl || shouldBypassProxy(parsedUrl.hostname)) { + return reqOptions + } + + const tunnelSocket = await connectThroughProxy( + proxyUrl, + parsedUrl.hostname, + parsedUrl.port || 443, + ) + + class TunnelAgent extends httpsModule.Agent { + createConnection(_options, callback) { + const secureSocket = tlsModule.connect({ + socket: tunnelSocket, + servername: parsedUrl.hostname, + }) + + if (typeof callback === 'function') { + if (typeof secureSocket.once === 'function') { + let settled = false + const finish = (error) => { + if (settled) return + settled = true + callback(error || null, error ? undefined : secureSocket) + } + + secureSocket.once('secureConnect', () => finish(null)) + secureSocket.once('error', (error) => finish(error)) + } else { + callback(null, secureSocket) + } + } + + return secureSocket + } + } + + reqOptions.agent = new TunnelAgent({ keepAlive: false }) + return reqOptions + } + + async function httpGet(url, options = {}) { + const reqOptions = await buildRequestOptions(url, options) + + return new Promise((resolve, reject) => { + const req = httpsModule.get(reqOptions, (res) => { + if (res.statusCode === 301 || res.statusCode === 302) { + res.resume() + httpGet(new URL(res.headers.location, url).href, options) + .then(resolve) + .catch(reject) + return + } + + resolve(res) + }) + + req.on('error', reject) + req.setTimeout(options.timeout || requestTimeout, () => { + req.destroy() + reject(new Error('Request timeout.')) + }) + }) + } + + return { + getProxyUrl, + httpGet, + } +} + +module.exports = { + createReleaseHttpClient, +} diff --git a/freebuff/cli/release/index.js b/freebuff/cli/release/index.js index 56d8539df..db7fe566a 100644 --- a/freebuff/cli/release/index.js +++ b/freebuff/cli/release/index.js @@ -6,10 +6,10 @@ const http = require('http') const https = require('https') const os = require('os') const path = require('path') -const tls = require('tls') const zlib = require('zlib') const tar = require('tar') +const { createReleaseHttpClient } = require('./http') const packageName = 'freebuff' @@ -66,6 +66,11 @@ function createConfig(packageName) { } const CONFIG = createConfig(packageName) +const { getProxyUrl, httpGet } = createReleaseHttpClient({ + env: process.env, + userAgent: CONFIG.userAgent, + requestTimeout: CONFIG.requestTimeout, +}) function getPostHogConfig() { const apiKey = @@ -130,76 +135,6 @@ function trackUpdateFailed(errorMessage, version, context = {}) { } } -function getProxyUrl() { - return ( - process.env.HTTPS_PROXY || - process.env.https_proxy || - process.env.HTTP_PROXY || - process.env.http_proxy || - null - ) -} - -function shouldBypassProxy(hostname) { - const noProxy = process.env.NO_PROXY || process.env.no_proxy || '' - if (!noProxy) return false - const domains = noProxy.split(',').map((d) => d.trim().toLowerCase().replace(/:\d+$/, '')) - const host = hostname.toLowerCase() - return domains.some((d) => { - if (d === '*') return true - if (d.startsWith('.')) return host.endsWith(d) || host === d.slice(1) - return host === d || host.endsWith('.' + d) - }) -} - -function connectThroughProxy(proxyUrl, targetHost, targetPort) { - return new Promise((resolve, reject) => { - const proxy = new URL(proxyUrl) - const isHttpsProxy = proxy.protocol === 'https:' - const connectOptions = { - hostname: proxy.hostname, - port: proxy.port || (isHttpsProxy ? 443 : 80), - method: 'CONNECT', - path: `${targetHost}:${targetPort}`, - headers: { - Host: `${targetHost}:${targetPort}`, - }, - } - - if (proxy.username || proxy.password) { - const auth = Buffer.from( - `${decodeURIComponent(proxy.username || '')}:${decodeURIComponent(proxy.password || '')}`, - ).toString('base64') - connectOptions.headers['Proxy-Authorization'] = `Basic ${auth}` - } - - const transport = isHttpsProxy ? https : http - const req = transport.request(connectOptions) - - req.on('connect', (res, socket) => { - if (res.statusCode === 200) { - resolve(socket) - } else { - socket.destroy() - reject( - new Error(`Proxy CONNECT failed with status ${res.statusCode}`), - ) - } - }) - - req.on('error', (err) => { - reject(new Error(`Proxy connection failed: ${err.message}`)) - }) - - req.setTimeout(CONFIG.requestTimeout, () => { - req.destroy() - reject(new Error('Proxy connection timeout.')) - }) - - req.end() - }) -} - const PLATFORM_TARGETS = { 'linux-x64': `${packageName}-linux-x64.tar.gz`, 'linux-arm64': `${packageName}-linux-arm64.tar.gz`, @@ -224,54 +159,6 @@ const term = { }, } -async function httpGet(url, options = {}) { - const parsedUrl = new URL(url) - const proxyUrl = getProxyUrl() - - const reqOptions = { - hostname: parsedUrl.hostname, - path: parsedUrl.pathname + parsedUrl.search, - headers: { - 'User-Agent': CONFIG.userAgent, - ...options.headers, - }, - } - - if (proxyUrl && !shouldBypassProxy(parsedUrl.hostname)) { - const tunnelSocket = await connectThroughProxy( - proxyUrl, - parsedUrl.hostname, - parsedUrl.port || 443, - ) - reqOptions.agent = false - reqOptions.createConnection = () => - tls.connect({ - socket: tunnelSocket, - servername: parsedUrl.hostname, - }) - } - - return new Promise((resolve, reject) => { - const req = https.get(reqOptions, (res) => { - if (res.statusCode === 302 || res.statusCode === 301) { - res.resume() - return httpGet(new URL(res.headers.location, url).href, options) - .then(resolve) - .catch(reject) - } - resolve(res) - }) - - req.on('error', reject) - - const timeout = options.timeout || CONFIG.requestTimeout - req.setTimeout(timeout, () => { - req.destroy() - reject(new Error('Request timeout.')) - }) - }) -} - async function getLatestVersion() { try { const res = await httpGet( diff --git a/freebuff/cli/release/package.json b/freebuff/cli/release/package.json index 50a6b6b39..3ca67ed82 100644 --- a/freebuff/cli/release/package.json +++ b/freebuff/cli/release/package.json @@ -12,6 +12,7 @@ }, "files": [ "index.js", + "http.js", "postinstall.js", "README.md" ], From 94d33230f2241bf3bf24aacae5e87058dbab8112 Mon Sep 17 00:00:00 2001 From: "aether-agent[bot]" <258877100+aether-agent[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 00:26:48 -0700 Subject: [PATCH 7/7] Remove evalbuff and expensivebuff (#493) Co-authored-by: CodebuffAI <189203002+CodebuffAI@users.noreply.github.com>