From 7c76a702497473bf4ae19f73f248eaaa433a096e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:02:52 +0000 Subject: [PATCH 1/3] Initial plan From ea3f6d61dd0e0ab765aa5beb64d64f7f0e28fd14 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:10:20 +0000 Subject: [PATCH 2/3] fix(cli-proxy): bind tunnel on ipv4 and ipv6 loopback --- containers/cli-proxy/tcp-tunnel.js | 36 ++++++-- scripts/ci/tcp-tunnel.test.ts | 142 +++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 9 deletions(-) create mode 100644 scripts/ci/tcp-tunnel.test.ts diff --git a/containers/cli-proxy/tcp-tunnel.js b/containers/cli-proxy/tcp-tunnel.js index cf911af8a..9f8759248 100644 --- a/containers/cli-proxy/tcp-tunnel.js +++ b/containers/cli-proxy/tcp-tunnel.js @@ -38,7 +38,12 @@ if (isNaN(remotePort) || remotePort < 1 || remotePort > 65535) { process.exit(1); } -const server = net.createServer(client => { +const bindHosts = ['127.0.0.1', '::1']; +let startedServers = 0; +let readyLogged = false; +const servers = []; + +function handleConnection(client) { const clientAddr = `${client.remoteAddress}:${client.remotePort}`; console.error(`[tcp-tunnel] Connection from ${sanitizeForLog(clientAddr)}`); const upstream = net.connect(remotePort, remoteHost); @@ -47,13 +52,26 @@ const server = net.createServer(client => { client.on('error', (err) => { console.error(`[tcp-tunnel] Client error (${sanitizeForLog(clientAddr)}): ${sanitizeForLog(err.message)}`); upstream.destroy(); }); upstream.on('error', (err) => { console.error(`[tcp-tunnel] Upstream error (${sanitizeForLog(clientAddr)}): ${sanitizeForLog(err.message)}`); client.destroy(); }); client.on('close', () => { console.error(`[tcp-tunnel] Connection closed: ${sanitizeForLog(clientAddr)}`); }); -}); +} -server.on('error', (err) => { - console.error('[tcp-tunnel] Server error:', sanitizeForLog(err.message)); - process.exit(1); -}); +for (const bindHost of bindHosts) { + const server = net.createServer(handleConnection); + servers.push(server); + server.on('error', (err) => { + const errCode = err && typeof err === 'object' && 'code' in err ? err.code : undefined; + if ((errCode === 'EADDRNOTAVAIL' || errCode === 'EAFNOSUPPORT') && bindHost === '::1') { + console.error(`[tcp-tunnel] IPv6 loopback unavailable, skipping ::1 bind (${sanitizeForLog(err.message)})`); + return; + } + console.error(`[tcp-tunnel] Server error (${bindHost}):`, sanitizeForLog(err.message)); + process.exit(1); + }); -server.listen(localPort, '127.0.0.1', () => { - console.log(`[tcp-tunnel] Forwarding localhost:${localPort} → ${remoteHost}:${remotePort}`); -}); + server.listen(localPort, bindHost, () => { + startedServers += 1; + if (!readyLogged && (startedServers === bindHosts.length || bindHost === '127.0.0.1')) { + readyLogged = true; + console.log(`[tcp-tunnel] Forwarding localhost:${localPort} → ${remoteHost}:${remotePort}`); + } + }); +} diff --git a/scripts/ci/tcp-tunnel.test.ts b/scripts/ci/tcp-tunnel.test.ts new file mode 100644 index 000000000..08c665f39 --- /dev/null +++ b/scripts/ci/tcp-tunnel.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from '@jest/globals'; +import net from 'net'; +import path from 'path'; +import { spawn, ChildProcess } from 'child_process'; + +function supportsIpv6Loopback(): Promise { + return new Promise((resolve) => { + const probe = net.createServer(); + probe.once('error', () => resolve(false)); + probe.listen(0, '::1', () => { + probe.close(() => resolve(true)); + }); + }); +} + +function createTcpServer(host: string): Promise<{ server: net.Server; port: number }> { + return new Promise((resolve, reject) => { + const server = net.createServer((socket) => { + socket.end('ok'); + }); + server.once('error', reject); + server.listen(0, host, () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to acquire port')); + return; + } + resolve({ server, port: address.port }); + }); + }); +} + +function getFreePort(host: string): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, host, () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to acquire free port')); + return; + } + const { port } = address; + server.close(() => resolve(port)); + }); + }); +} + +function waitForTunnelReady(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + const onExit = (code: number | null) => reject(new Error(`Tunnel exited before ready (code=${code})`)); + const onStdout = (chunk: Buffer) => { + if (chunk.toString().includes('Forwarding localhost:')) { + child.stdout?.off('data', onStdout); + child.off('exit', onExit); + resolve(); + } + }; + child.on('exit', onExit); + child.stdout?.on('data', onStdout); + }); +} + +function connect(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const socket = net.connect({ host, port }); + const timeout = setTimeout(() => { + if (settled) { + return; + } + settled = true; + socket.destroy(); + reject(new Error(`Timed out connecting to ${host}:${port}`)); + }, 1000); + + socket.on('connect', () => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + socket.destroy(); + resolve(); + }); + + socket.on('error', (error: NodeJS.ErrnoException) => { + if (settled) { + return; + } + if (error.code === 'ECONNRESET') { + settled = true; + clearTimeout(timeout); + resolve(); + return; + } + settled = true; + clearTimeout(timeout); + reject(error); + }); + }); +} + +async function connectWithRetry(host: string, port: number, attempts = 20): Promise { + let lastError: unknown; + for (let i = 0; i < attempts; i += 1) { + try { + await connect(host, port); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + throw lastError; +} + +describe('cli-proxy tcp tunnel', () => { + it('binds localhost tunnel on both IPv4 and IPv6 loopback when IPv6 is available', async () => { + if (!await supportsIpv6Loopback()) { + return; + } + + const upstream = await createTcpServer('127.0.0.1'); + const tunnelPort = await getFreePort('127.0.0.1'); + const tunnelScript = path.join(process.cwd(), 'containers/cli-proxy/tcp-tunnel.js'); + const tunnel = spawn(process.execPath, [tunnelScript, String(tunnelPort), '127.0.0.1', String(upstream.port)], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + try { + await waitForTunnelReady(tunnel); + await connect('127.0.0.1', tunnelPort); + await connectWithRetry('::1', tunnelPort); + } finally { + tunnel.kill('SIGTERM'); + await new Promise((resolve) => upstream.server.close(() => resolve(undefined))); + } + + expect(tunnel.exitCode).not.toBe(1); + }, 10000); +}); From a68ec256d052a2f5c73767661c12fbdee32bde45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:03:09 +0000 Subject: [PATCH 3/3] fix(test): address review feedback on tcp-tunnel changes --- containers/cli-proxy/tcp-tunnel.js | 2 -- scripts/ci/tcp-tunnel.test.ts | 10 ++++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/containers/cli-proxy/tcp-tunnel.js b/containers/cli-proxy/tcp-tunnel.js index 9f8759248..5b14bb24a 100644 --- a/containers/cli-proxy/tcp-tunnel.js +++ b/containers/cli-proxy/tcp-tunnel.js @@ -41,7 +41,6 @@ if (isNaN(remotePort) || remotePort < 1 || remotePort > 65535) { const bindHosts = ['127.0.0.1', '::1']; let startedServers = 0; let readyLogged = false; -const servers = []; function handleConnection(client) { const clientAddr = `${client.remoteAddress}:${client.remotePort}`; @@ -56,7 +55,6 @@ function handleConnection(client) { for (const bindHost of bindHosts) { const server = net.createServer(handleConnection); - servers.push(server); server.on('error', (err) => { const errCode = err && typeof err === 'object' && 'code' in err ? err.code : undefined; if ((errCode === 'EADDRNOTAVAIL' || errCode === 'EAFNOSUPPORT') && bindHost === '::1') { diff --git a/scripts/ci/tcp-tunnel.test.ts b/scripts/ci/tcp-tunnel.test.ts index 08c665f39..621b3b6f2 100644 --- a/scripts/ci/tcp-tunnel.test.ts +++ b/scripts/ci/tcp-tunnel.test.ts @@ -16,6 +16,7 @@ function supportsIpv6Loopback(): Promise { function createTcpServer(host: string): Promise<{ server: net.Server; port: number }> { return new Promise((resolve, reject) => { const server = net.createServer((socket) => { + socket.on('error', () => {}); socket.end('ok'); }); server.once('error', reject); @@ -101,7 +102,7 @@ function connect(host: string, port: number): Promise { }); } -async function connectWithRetry(host: string, port: number, attempts = 20): Promise { +async function connectWithRetry(host: string, port: number, attempts = 5): Promise { let lastError: unknown; for (let i = 0; i < attempts; i += 1) { try { @@ -134,9 +135,10 @@ describe('cli-proxy tcp tunnel', () => { await connectWithRetry('::1', tunnelPort); } finally { tunnel.kill('SIGTERM'); - await new Promise((resolve) => upstream.server.close(() => resolve(undefined))); + await Promise.all([ + new Promise((resolve) => tunnel.once('exit', resolve)), + new Promise((resolve) => upstream.server.close(() => resolve(undefined))), + ]); } - - expect(tunnel.exitCode).not.toBe(1); }, 10000); });