From 6d0a2907460233385d30fa0306057b7f61ec6f6c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:29:19 +0000 Subject: [PATCH 1/4] Initial plan From 12f20f7c49c7e955dbde0fbc1dd0322b4556f516 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:37:47 +0000 Subject: [PATCH 2/4] feat: add WebSocket upgrade handling to api-proxy sidecar Add proxyWebSocket() function using raw CONNECT tunnel through Squid (no new dependencies). Handles WebSocket upgrade requests on all four proxy servers: OpenAI (10000), Anthropic (10001), Copilot (10002), OpenCode (10004). - Validates upgrade type and URL (rejects non-WebSocket, SSRF prevention) - Rate-limits the initial upgrade request - Opens CONNECT tunnel through Squid to upstream:443 - TLS-upgrades the tunnel and replays Upgrade request with auth injected - Strips client-supplied auth headers; injects proxy credentials - Bidirectionally pipes raw TCP sockets for the WebSocket session - Logs upgrade start/complete/failed events with metrics - Adds 12 unit tests covering validation and tunnel paths" Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/f1b847f0-b4fd-412c-b244-1132754fe11b Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- containers/api-proxy/server.js | 247 ++++++++++++++++++++++- containers/api-proxy/server.test.js | 298 +++++++++++++++++++++++++++- 2 files changed, 543 insertions(+), 2 deletions(-) diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index d03ff900..054ccfa5 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -12,6 +12,7 @@ const http = require('http'); const https = require('https'); +const tls = require('tls'); const { URL } = require('url'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { generateRequestId, sanitizeForLog, logRequest } = require('./logging'); @@ -423,6 +424,217 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath = }); } +/** + * Handle a WebSocket upgrade request by tunnelling through the Squid proxy. + * + * Flow: + * client --[HTTP Upgrade]--> proxy --[CONNECT]--> Squid:3128 --[TLS]--> upstream:443 + * + * Steps: + * 1. Validate the request (WebSocket upgrade only, relative URL) + * 2. Apply rate limiting (counts as one request, zero body bytes) + * 3. Open a CONNECT tunnel to targetHost:443 through Squid + * 4. TLS-handshake the tunnel + * 5. Replay the HTTP Upgrade request with auth headers injected + * 6. Bidirectionally pipe the raw TCP sockets + * + * No additional npm dependencies are required — only Node.js built-ins. + * + * @param {http.IncomingMessage} req - The incoming HTTP Upgrade request + * @param {import('net').Socket} socket - Raw TCP socket to the WebSocket client + * @param {Buffer} head - Any bytes already buffered after the upgrade headers + * @param {string} targetHost - Upstream hostname (e.g. 'api.openai.com') + * @param {Object} injectHeaders - Auth headers to inject (e.g. { Authorization: 'Bearer …' }) + * @param {string} provider - Provider name for logging and metrics + * @param {string} [basePath=''] - Optional base-path prefix for the upstream URL + */ +function proxyWebSocket(req, socket, head, targetHost, injectHeaders, provider, basePath = '') { + const startTime = Date.now(); + const clientRequestId = req.headers['x-request-id']; + const requestId = isValidRequestId(clientRequestId) ? clientRequestId : generateRequestId(); + + // ── Validate: only forward WebSocket upgrades ────────────────────────── + const upgradeType = (req.headers['upgrade'] || '').toLowerCase(); + if (upgradeType !== 'websocket') { + logRequest('warn', 'websocket_upgrade_rejected', { + request_id: requestId, + provider, + path: sanitizeForLog(req.url), + reason: 'unsupported upgrade type', + upgrade: sanitizeForLog(req.headers['upgrade'] || ''), + }); + socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); + socket.destroy(); + return; + } + + // ── Validate: relative path only (prevent SSRF) ──────────────────────── + if (!req.url || !req.url.startsWith('/')) { + logRequest('warn', 'websocket_upgrade_rejected', { + request_id: requestId, + provider, + path: sanitizeForLog(req.url), + reason: 'URL must be a relative path', + }); + socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); + socket.destroy(); + return; + } + + const upstreamPath = buildUpstreamPath(req.url, targetHost, basePath); + + // ── Rate limit (counts as one request, frames are not tracked) ────────── + const rateCheck = limiter.check(provider, 0); + if (!rateCheck.allowed) { + metrics.increment('rate_limit_rejected_total', { provider, limit_type: rateCheck.limitType }); + logRequest('warn', 'rate_limited', { + request_id: requestId, + provider, + limit_type: rateCheck.limitType, + limit: rateCheck.limit, + retry_after: rateCheck.retryAfter, + }); + socket.write( + `HTTP/1.1 429 Too Many Requests\r\nRetry-After: ${rateCheck.retryAfter}\r\nConnection: close\r\n\r\n` + ); + socket.destroy(); + return; + } + + logRequest('info', 'websocket_upgrade_start', { + request_id: requestId, + provider, + path: sanitizeForLog(req.url), + upstream_host: targetHost, + }); + metrics.gaugeInc('active_requests', { provider }); + + // finalize() must be called exactly once when the WebSocket session ends. + let finalized = false; + function finalize(isError, description) { + if (finalized) return; + finalized = true; + const duration = Date.now() - startTime; + metrics.gaugeDec('active_requests', { provider }); + if (isError) { + metrics.increment('requests_errors_total', { provider }); + logRequest('error', 'websocket_upgrade_failed', { + request_id: requestId, + provider, + path: sanitizeForLog(req.url), + duration_ms: duration, + error: sanitizeForLog(String(description || 'unknown error')), + }); + } else { + metrics.increment('requests_total', { provider, method: 'GET', status_class: '1xx' }); + metrics.observe('request_duration_ms', duration, { provider }); + logRequest('info', 'websocket_upgrade_complete', { + request_id: requestId, + provider, + path: sanitizeForLog(req.url), + duration_ms: duration, + }); + } + } + + // abort(): called before the socket pipe is established (pre-TLS errors). + // Sends a 502 to the client and finalizes with an error. + function abort(reason, ...extra) { + finalize(true, reason); + if (!socket.destroyed && socket.writable) { + socket.write('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n'); + } + socket.destroy(); + for (const s of extra) { + if (s && !s.destroyed) s.destroy(); + } + } + + // ── Require Squid proxy ──────────────────────────────────────────────── + if (!HTTPS_PROXY) { + abort('No Squid proxy configured (HTTPS_PROXY not set)'); + return; + } + + let proxyUrl; + try { + proxyUrl = new URL(HTTPS_PROXY); + } catch (err) { + abort(`Invalid proxy URL: ${err.message}`); + return; + } + + const proxyHost = proxyUrl.hostname; + const proxyPort = parseInt(proxyUrl.port, 10) || 3128; + + // ── Step 1: CONNECT tunnel through Squid to targetHost:443 ──────────── + const connectReq = http.request({ + host: proxyHost, + port: proxyPort, + method: 'CONNECT', + path: `${targetHost}:443`, + headers: { 'Host': `${targetHost}:443` }, + }); + + connectReq.once('error', (err) => abort(`CONNECT error: ${err.message}`)); + + connectReq.once('connect', (connectRes, tunnel) => { + if (connectRes.statusCode !== 200) { + abort(`CONNECT failed: HTTP ${connectRes.statusCode}`, tunnel); + return; + } + + // ── Step 2: TLS-upgrade the raw tunnel ────────────────────────────── + const tlsSocket = tls.connect({ socket: tunnel, servername: targetHost, rejectUnauthorized: true }); + + // Pre-TLS error handler: removed once TLS is established. + const onTlsError = (err) => abort(`TLS handshake error: ${err.message}`, tunnel); + tlsSocket.once('error', onTlsError); + + tlsSocket.once('secureConnect', () => { + // TLS connected — swap to post-connection teardown error handlers. + tlsSocket.removeListener('error', onTlsError); + + // ── Step 3: Replay the HTTP Upgrade request with auth injected ──── + const forwardHeaders = {}; + for (const [name, value] of Object.entries(req.headers)) { + if (!shouldStripHeader(name)) { + forwardHeaders[name] = value; + } + } + Object.assign(forwardHeaders, injectHeaders); + forwardHeaders['host'] = targetHost; // Fix Host header for upstream + + let upgradeReqStr = `GET ${upstreamPath} HTTP/1.1\r\n`; + for (const [name, value] of Object.entries(forwardHeaders)) { + upgradeReqStr += `${name}: ${value}\r\n`; + } + upgradeReqStr += '\r\n'; + tlsSocket.write(upgradeReqStr); + + // Forward any bytes already buffered before the pipe + if (head && head.length > 0) { + tlsSocket.write(head); + } + + // ── Step 4: Bidirectional raw socket relay ───────────────────── + tlsSocket.pipe(socket); + socket.pipe(tlsSocket); + + // Finalize once when either side closes; destroy the other side. + function onClose() { finalize(false); socket.destroy(); tlsSocket.destroy(); } + socket.once('close', onClose); + tlsSocket.once('close', onClose); + + // Suppress unhandled-error crashes; destroy triggers the close handler. + socket.on('error', () => socket.destroy()); + tlsSocket.on('error', () => tlsSocket.destroy()); + }); + }); + + connectReq.end(); +} + /** * Build the enhanced health response (superset of original format). */ @@ -476,6 +688,12 @@ if (require.main === module) { }, 'openai', OPENAI_API_BASE_PATH); }); + server.on('upgrade', (req, socket, head) => { + proxyWebSocket(req, socket, head, OPENAI_API_TARGET, { + 'Authorization': `Bearer ${OPENAI_API_KEY}`, + }, 'openai', OPENAI_API_BASE_PATH); + }); + server.listen(HEALTH_PORT, '0.0.0.0', () => { logRequest('info', 'server_start', { message: `OpenAI proxy listening on port ${HEALTH_PORT}`, target: OPENAI_API_TARGET }); }); @@ -488,6 +706,11 @@ if (require.main === module) { res.end(JSON.stringify({ error: 'OpenAI proxy not configured (no OPENAI_API_KEY)' })); }); + server.on('upgrade', (req, socket) => { + socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n'); + socket.destroy(); + }); + server.listen(HEALTH_PORT, '0.0.0.0', () => { logRequest('info', 'server_start', { message: `Health endpoint listening on port ${HEALTH_PORT} (OpenAI not configured)` }); }); @@ -513,6 +736,14 @@ if (require.main === module) { proxyRequest(req, res, ANTHROPIC_API_TARGET, anthropicHeaders, 'anthropic', ANTHROPIC_API_BASE_PATH); }); + server.on('upgrade', (req, socket, head) => { + const anthropicHeaders = { 'x-api-key': ANTHROPIC_API_KEY }; + if (!req.headers['anthropic-version']) { + anthropicHeaders['anthropic-version'] = '2023-06-01'; + } + proxyWebSocket(req, socket, head, ANTHROPIC_API_TARGET, anthropicHeaders, 'anthropic', ANTHROPIC_API_BASE_PATH); + }); + server.listen(10001, '0.0.0.0', () => { logRequest('info', 'server_start', { message: 'Anthropic proxy listening on port 10001', target: ANTHROPIC_API_TARGET }); }); @@ -537,6 +768,12 @@ if (require.main === module) { }, 'copilot'); }); + copilotServer.on('upgrade', (req, socket, head) => { + proxyWebSocket(req, socket, head, COPILOT_API_TARGET, { + 'Authorization': `Bearer ${COPILOT_GITHUB_TOKEN}`, + }, 'copilot'); + }); + copilotServer.listen(10002, '0.0.0.0', () => { logRequest('info', 'server_start', { message: 'GitHub Copilot proxy listening on port 10002' }); }); @@ -571,6 +808,14 @@ if (require.main === module) { proxyRequest(req, res, ANTHROPIC_API_TARGET, anthropicHeaders); }); + opencodeServer.on('upgrade', (req, socket, head) => { + const anthropicHeaders = { 'x-api-key': ANTHROPIC_API_KEY }; + if (!req.headers['anthropic-version']) { + anthropicHeaders['anthropic-version'] = '2023-06-01'; + } + proxyWebSocket(req, socket, head, ANTHROPIC_API_TARGET, anthropicHeaders, 'opencode'); + }); + opencodeServer.listen(10004, '0.0.0.0', () => { console.log(`[API Proxy] OpenCode proxy listening on port 10004 (-> Anthropic at ${ANTHROPIC_API_TARGET})`); }); @@ -589,4 +834,4 @@ if (require.main === module) { } // Export for testing -module.exports = { deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath }; +module.exports = { deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket }; diff --git a/containers/api-proxy/server.test.js b/containers/api-proxy/server.test.js index 36b645c6..97829b04 100644 --- a/containers/api-proxy/server.test.js +++ b/containers/api-proxy/server.test.js @@ -2,7 +2,10 @@ * Tests for api-proxy server.js */ -const { deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath } = require('./server'); +const http = require('http'); +const tls = require('tls'); +const { EventEmitter } = require('events'); +const { deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket } = require('./server'); describe('deriveCopilotApiTarget', () => { let originalEnv; @@ -266,3 +269,296 @@ describe('buildUpstreamPath', () => { }); }); }); + +// ── Helpers for proxyWebSocket tests ────────────────────────────────────────── + +/** Create a minimal mock socket with write/destroy spies. */ +function makeMockSocket() { + const s = new EventEmitter(); + s.write = jest.fn(); + s.destroy = jest.fn(); + s.pipe = jest.fn(); + s.writable = true; + s.destroyed = false; + return s; +} + +/** Create a mock HTTP request for a WebSocket upgrade. */ +function makeUpgradeReq(overrides = {}) { + return { + url: '/v1/responses', + headers: { + 'upgrade': 'websocket', + 'connection': 'Upgrade', + 'sec-websocket-key': 'test-ws-key==', + 'sec-websocket-version': '13', + 'host': '172.30.0.30', + ...overrides.headers, + }, + ...overrides, + }; +} + +describe('proxyWebSocket', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── Request validation ───────────────────────────────────────────────────── + + describe('request validation', () => { + it('rejects a non-WebSocket upgrade (e.g. h2c) with 400', () => { + const socket = makeMockSocket(); + proxyWebSocket(makeUpgradeReq({ headers: { 'upgrade': 'h2c' } }), socket, Buffer.alloc(0), 'api.openai.com', {}, 'openai'); + expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('HTTP/1.1 400 Bad Request')); + expect(socket.destroy).toHaveBeenCalled(); + }); + + it('rejects an upgrade with no Upgrade header with 400', () => { + const socket = makeMockSocket(); + const req = makeUpgradeReq(); + delete req.headers['upgrade']; + proxyWebSocket(req, socket, Buffer.alloc(0), 'api.openai.com', {}, 'openai'); + expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('HTTP/1.1 400 Bad Request')); + expect(socket.destroy).toHaveBeenCalled(); + }); + + it('rejects an absolute URL with 400 (SSRF prevention)', () => { + const socket = makeMockSocket(); + proxyWebSocket(makeUpgradeReq({ url: 'https://evil.com/v1/responses' }), socket, Buffer.alloc(0), 'api.openai.com', {}, 'openai'); + expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('HTTP/1.1 400 Bad Request')); + expect(socket.destroy).toHaveBeenCalled(); + }); + + it('rejects a null URL with 400', () => { + const socket = makeMockSocket(); + proxyWebSocket(makeUpgradeReq({ url: null }), socket, Buffer.alloc(0), 'api.openai.com', {}, 'openai'); + expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('HTTP/1.1 400 Bad Request')); + expect(socket.destroy).toHaveBeenCalled(); + }); + }); + + // ── Proxy config errors ──────────────────────────────────────────────────── + + describe('proxy configuration errors', () => { + it('returns 502 when HTTPS_PROXY is not configured', () => { + // The module was loaded without HTTPS_PROXY; proxyWebSocket should fail-safe. + const socket = makeMockSocket(); + proxyWebSocket(makeUpgradeReq(), socket, Buffer.alloc(0), 'api.openai.com', {}, 'openai'); + expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('HTTP/1.1 502 Bad Gateway')); + expect(socket.destroy).toHaveBeenCalled(); + }); + }); + + // ── Network tunnel tests (module loaded with HTTPS_PROXY set) ───────────── + + describe('CONNECT tunnel and auth injection', () => { + let wsProxy; + + beforeAll(() => { + // Re-require server with HTTPS_PROXY so proxyWebSocket uses the proxy URL. + process.env.HTTPS_PROXY = 'http://127.0.0.1:3128'; + jest.resetModules(); + wsProxy = require('./server').proxyWebSocket; + }); + + afterAll(() => { + delete process.env.HTTPS_PROXY; + jest.resetModules(); + }); + + it('returns 502 when the CONNECT response is not 200', () => { + const socket = makeMockSocket(); + const connectReq = new EventEmitter(); + connectReq.end = jest.fn(); + const tunnel = makeMockSocket(); + + jest.spyOn(http, 'request').mockReturnValue(connectReq); + setImmediate(() => connectReq.emit('connect', { statusCode: 407 }, tunnel)); + + wsProxy(makeUpgradeReq(), socket, Buffer.alloc(0), 'api.openai.com', { 'Authorization': 'Bearer key' }, 'openai'); + + return new Promise(resolve => setImmediate(() => { + expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('HTTP/1.1 502 Bad Gateway')); + expect(socket.destroy).toHaveBeenCalled(); + expect(tunnel.destroy).toHaveBeenCalled(); + resolve(); + })); + }); + + it('returns 502 when the CONNECT request emits an error', () => { + const socket = makeMockSocket(); + const connectReq = new EventEmitter(); + connectReq.end = jest.fn(); + + jest.spyOn(http, 'request').mockReturnValue(connectReq); + setImmediate(() => connectReq.emit('error', new Error('connection refused'))); + + wsProxy(makeUpgradeReq(), socket, Buffer.alloc(0), 'api.openai.com', { 'Authorization': 'Bearer key' }, 'openai'); + + return new Promise(resolve => setImmediate(() => { + expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('HTTP/1.1 502 Bad Gateway')); + expect(socket.destroy).toHaveBeenCalled(); + resolve(); + })); + }); + + it('returns 502 when TLS handshake fails', () => { + const socket = makeMockSocket(); + const connectReq = new EventEmitter(); + connectReq.end = jest.fn(); + const tunnel = makeMockSocket(); + const tlsSocket = new EventEmitter(); + tlsSocket.write = jest.fn(); + tlsSocket.destroy = jest.fn(); + tlsSocket.pipe = jest.fn(); + + jest.spyOn(http, 'request').mockReturnValue(connectReq); + jest.spyOn(tls, 'connect').mockReturnValue(tlsSocket); + + setImmediate(() => { + connectReq.emit('connect', { statusCode: 200 }, tunnel); + setImmediate(() => tlsSocket.emit('error', new Error('certificate unknown'))); + }); + + wsProxy(makeUpgradeReq(), socket, Buffer.alloc(0), 'api.openai.com', { 'Authorization': 'Bearer key' }, 'openai'); + + return new Promise(resolve => setTimeout(() => { + expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('HTTP/1.1 502 Bad Gateway')); + expect(socket.destroy).toHaveBeenCalled(); + resolve(); + }, 30)); + }); + + it('injects Authorization header and fixes Host header in the upgrade request', () => { + const socket = makeMockSocket(); + const connectReq = new EventEmitter(); + connectReq.end = jest.fn(); + const tunnel = makeMockSocket(); + const tlsSocket = new EventEmitter(); + tlsSocket.write = jest.fn(); + tlsSocket.destroy = jest.fn(); + tlsSocket.pipe = jest.fn(); + + jest.spyOn(http, 'request').mockReturnValue(connectReq); + jest.spyOn(tls, 'connect').mockReturnValue(tlsSocket); + + setImmediate(() => { + connectReq.emit('connect', { statusCode: 200 }, tunnel); + setImmediate(() => tlsSocket.emit('secureConnect')); + }); + + wsProxy(makeUpgradeReq(), socket, Buffer.alloc(0), 'api.openai.com', { 'Authorization': 'Bearer secret' }, 'openai'); + + return new Promise(resolve => setTimeout(() => { + // The upgrade request is written as a string to tlsSocket + const upgradeWrite = tlsSocket.write.mock.calls.find( + c => typeof c[0] === 'string' && c[0].startsWith('GET ') + ); + expect(upgradeWrite).toBeDefined(); + const upgradeReqStr = upgradeWrite[0]; + expect(upgradeReqStr).toContain('Authorization: Bearer secret'); + expect(upgradeReqStr).toContain('host: api.openai.com'); + // Both sides should be piped + expect(tlsSocket.pipe).toHaveBeenCalledWith(socket); + expect(socket.pipe).toHaveBeenCalledWith(tlsSocket); + resolve(); + }, 30)); + }); + + it('strips client-supplied auth headers before forwarding', () => { + const socket = makeMockSocket(); + const connectReq = new EventEmitter(); + connectReq.end = jest.fn(); + const tunnel = makeMockSocket(); + const tlsSocket = new EventEmitter(); + tlsSocket.write = jest.fn(); + tlsSocket.destroy = jest.fn(); + tlsSocket.pipe = jest.fn(); + + jest.spyOn(http, 'request').mockReturnValue(connectReq); + jest.spyOn(tls, 'connect').mockReturnValue(tlsSocket); + + setImmediate(() => { + connectReq.emit('connect', { statusCode: 200 }, tunnel); + setImmediate(() => tlsSocket.emit('secureConnect')); + }); + + const req = makeUpgradeReq({ + headers: { + 'upgrade': 'websocket', + 'authorization': 'Bearer client-supplied', // must be stripped + 'x-api-key': 'client-api-key', // must be stripped + 'sec-websocket-key': 'ws-key==', + 'sec-websocket-version': '13', + }, + }); + + wsProxy(req, socket, Buffer.alloc(0), 'api.openai.com', { 'Authorization': 'Bearer injected' }, 'openai'); + + return new Promise(resolve => setTimeout(() => { + const upgradeWrite = tlsSocket.write.mock.calls.find( + c => typeof c[0] === 'string' && c[0].startsWith('GET ') + ); + expect(upgradeWrite).toBeDefined(); + const upgradeReqStr = upgradeWrite[0]; + // Client-supplied auth is stripped; injected auth is present + expect(upgradeReqStr).not.toContain('client-supplied'); + expect(upgradeReqStr).not.toContain('client-api-key'); + expect(upgradeReqStr).toContain('Bearer injected'); + resolve(); + }, 30)); + }); + + it('forwards the CONNECT request to the configured Squid proxy host/port', () => { + const socket = makeMockSocket(); + const connectReq = new EventEmitter(); + connectReq.end = jest.fn(); + + let capturedOptions; + jest.spyOn(http, 'request').mockImplementation((options) => { + capturedOptions = options; + return connectReq; + }); + + wsProxy(makeUpgradeReq(), socket, Buffer.alloc(0), 'api.openai.com', {}, 'openai'); + + expect(capturedOptions).toBeDefined(); + expect(capturedOptions.method).toBe('CONNECT'); + expect(capturedOptions.path).toBe('api.openai.com:443'); + expect(capturedOptions.host).toBe('127.0.0.1'); + expect(capturedOptions.port).toBe(3128); + }); + + it('forwards buffered head bytes to the upstream after upgrade', () => { + const socket = makeMockSocket(); + const connectReq = new EventEmitter(); + connectReq.end = jest.fn(); + const tunnel = makeMockSocket(); + const tlsSocket = new EventEmitter(); + tlsSocket.write = jest.fn(); + tlsSocket.destroy = jest.fn(); + tlsSocket.pipe = jest.fn(); + + jest.spyOn(http, 'request').mockReturnValue(connectReq); + jest.spyOn(tls, 'connect').mockReturnValue(tlsSocket); + + setImmediate(() => { + connectReq.emit('connect', { statusCode: 200 }, tunnel); + setImmediate(() => tlsSocket.emit('secureConnect')); + }); + + const headBytes = Buffer.from([0x81, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f]); + wsProxy(makeUpgradeReq(), socket, headBytes, 'api.openai.com', { 'Authorization': 'Bearer k' }, 'openai'); + + return new Promise(resolve => setTimeout(() => { + // The head buffer should have been written to tlsSocket + const bufWrite = tlsSocket.write.mock.calls.find(c => Buffer.isBuffer(c[0])); + expect(bufWrite).toBeDefined(); + expect(bufWrite[0]).toEqual(headBytes); + resolve(); + }, 30)); + }); + }); +}); + From 41fe490b2cd0015a796259ad757c46aeb103d2e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:40:39 +0000 Subject: [PATCH 3/4] fix: address code review feedback on proxyWebSocket cleanup and test clarity - Use separate close handlers for socket/tlsSocket to avoid double-destroy - Add comment explaining WebSocket frame bytes in test fixture Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/f1b847f0-b4fd-412c-b244-1132754fe11b Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- containers/api-proxy/server.js | 5 ++--- containers/api-proxy/server.test.js | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 054ccfa5..35ddd194 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -622,9 +622,8 @@ function proxyWebSocket(req, socket, head, targetHost, injectHeaders, provider, socket.pipe(tlsSocket); // Finalize once when either side closes; destroy the other side. - function onClose() { finalize(false); socket.destroy(); tlsSocket.destroy(); } - socket.once('close', onClose); - tlsSocket.once('close', onClose); + socket.once('close', () => { finalize(false); tlsSocket.destroy(); }); + tlsSocket.once('close', () => { finalize(false); socket.destroy(); }); // Suppress unhandled-error crashes; destroy triggers the close handler. socket.on('error', () => socket.destroy()); diff --git a/containers/api-proxy/server.test.js b/containers/api-proxy/server.test.js index 97829b04..cd0c8ebe 100644 --- a/containers/api-proxy/server.test.js +++ b/containers/api-proxy/server.test.js @@ -548,7 +548,7 @@ describe('proxyWebSocket', () => { setImmediate(() => tlsSocket.emit('secureConnect')); }); - const headBytes = Buffer.from([0x81, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f]); + const headBytes = Buffer.from([0x81, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f]); // WS text frame: FIN=1, opcode=1, len=5, payload='Hello' wsProxy(makeUpgradeReq(), socket, headBytes, 'api.openai.com', { 'Authorization': 'Bearer k' }, 'openai'); return new Promise(resolve => setTimeout(() => { From 7008df799a04c6a7422a5d5257a029c89b7aa0e8 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sat, 28 Mar 2026 10:19:29 -0700 Subject: [PATCH 4/4] fix(ci): add checkout step to detection job for local awf build The detection job (added by gh-aw v0.64.2 compiler) runs npm ci and npm run build but has no Checkout repository step, causing npm ci to fail with EUSAGE (missing package-lock.json). Add checkout step to the lock file and update the postprocessing script to automatically inject checkout steps before awf build steps in any job that lacks one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/smoke-codex.lock.yml | 4 ++ scripts/ci/postprocess-smoke-workflows.ts | 45 +++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml index cdc9be04..a9c16584 100644 --- a/.github/workflows/smoke-codex.lock.yml +++ b/.github/workflows/smoke-codex.lock.yml @@ -1173,6 +1173,10 @@ jobs: package-manager-cache: false - name: Install Codex CLI run: npm install -g @openai/codex@latest + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Install awf dependencies run: npm ci - name: Build awf diff --git a/scripts/ci/postprocess-smoke-workflows.ts b/scripts/ci/postprocess-smoke-workflows.ts index c773b5c1..867c6cb4 100644 --- a/scripts/ci/postprocess-smoke-workflows.ts +++ b/scripts/ci/postprocess-smoke-workflows.ts @@ -110,6 +110,51 @@ for (const workflowPath of workflowPaths) { console.log(` Replaced ${matches.length} awf install step(s) with local build`); } + // Ensure a "Checkout repository" step exists before "Install awf dependencies" + // in every job. The gh-aw compiler may add jobs (e.g. detection) that reference + // install_awf_binary.sh but don't include a checkout step. After we replace the + // install step with local build steps (npm ci / npm run build), they need the + // repo checked out. We inject a checkout step right before "Install awf dependencies" + // if one doesn't already appear earlier in the same job. + const lines = content.split('\n'); + let injectedCheckouts = 0; + for (let i = 0; i < lines.length; i++) { + const installMatch = lines[i].match(/^(\s+)- name: Install awf dependencies$/); + if (!installMatch) continue; + + // Walk backwards to find the job boundary (non-indented key ending with ':') + // and check whether a "Checkout repository" step exists in between. + let hasCheckout = false; + for (let j = i - 1; j >= 0; j--) { + if (/^\s+- name: Checkout repository/.test(lines[j])) { + hasCheckout = true; + break; + } + // Job-level key (e.g. " agent:" or " detection:") marks the boundary + if (/^ \S+:/.test(lines[j]) && !lines[j].startsWith(' ')) { + break; + } + } + + if (!hasCheckout) { + const indent = installMatch[1]; + const checkoutStep = [ + `${indent}- name: Checkout repository`, + `${indent} uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2`, + `${indent} with:`, + `${indent} persist-credentials: false`, + ].join('\n'); + lines.splice(i, 0, checkoutStep); + injectedCheckouts++; + i += 4; // Skip past the inserted lines + } + } + if (injectedCheckouts > 0) { + content = lines.join('\n'); + modified = true; + console.log(` Injected ${injectedCheckouts} checkout step(s) before awf build steps`); + } + // Remove sparse-checkout from agent job checkout (need full repo for npm build) const sparseMatches = content.match(sparseCheckoutRegex); if (sparseMatches) {