opencode/packages/client/test/node/proxy.test.ts
2026-07-28 16:55:53 +10:00

452 lines
16 KiB
TypeScript

import { createHash } from "node:crypto"
import { once } from "node:events"
import { mkdtemp, rm } from "node:fs/promises"
import { createServer } from "node:http"
import { Socket } from "node:net"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Duplex } from "node:stream"
import { connect, type TLSSocket } from "node:tls"
import { pathToFileURL } from "node:url"
import { describe, expect, test } from "bun:test"
import { createBrowserProxy } from "../../src/node/browser/proxy"
describe("browser proxy", () => {
test("forwards authenticated CONNECT streams without directly dialing the target", async () => {
let directConnections = 0
const decoy = createServer((_incoming, response) => response.end("DIRECT DIAL"))
decoy.on("connection", () => directConnections++)
await new Promise<void>((resolve) => decoy.listen(0, "127.0.0.1", resolve))
const decoyAddress = decoy.address()
if (decoyAddress === null || typeof decoyAddress === "string") throw new Error("decoy server did not bind TCP")
const target = createServer((incoming, response) => response.end(`TARGET ${incoming.method} ${incoming.url}`))
await new Promise<void>((resolve) => target.listen(0, "127.0.0.1", resolve))
const targetAddress = target.address()
if (targetAddress === null || typeof targetAddress === "string") throw new Error("target server did not bind TCP")
const opened: Array<{ host: string; port: number }> = []
const proxy = await createBrowserProxy({
connect: async (destination, signal) => {
opened.push(destination)
const socket = new Socket({ allowHalfOpen: true })
const abort = () => socket.destroy(new Error("cancelled"))
signal.addEventListener("abort", abort, { once: true })
socket.once("close", () => signal.removeEventListener("abort", abort))
// Emulate the server-side tunnel dial. The local decoy must never receive a connection.
socket.connect(targetAddress.port, "127.0.0.1")
await once(socket, "connect")
return socket
},
})
try {
const tunnel = await proxyConnect(proxy, `127.0.0.1:${decoyAddress.port}`)
expect(tunnel.status).toBe(200)
tunnel.socket.write(
`GET /connected HTTP/1.1\r\nHost: 127.0.0.1:${decoyAddress.port}\r\nConnection: close\r\n\r\n`,
)
const response = await readAll(tunnel.socket, tunnel.leftover)
expect(response).toContain("TARGET GET /connected")
expect(opened).toEqual([{ host: "127.0.0.1", port: decoyAddress.port }])
const certificate = await peerCertificate(proxy)
expect(`sha256/${createHash("sha256").update(certificate).digest("base64")}`).toBe(proxy.certificateFingerprint)
expect(directConnections).toBe(0)
} finally {
await proxy.close()
await Promise.all([
new Promise<void>((resolve) => target.close(() => resolve())),
new Promise<void>((resolve) => decoy.close(() => resolve())),
])
}
})
test("forwards absolute-form HTTP with Node framing and closes held-open targets", async () => {
expect(await nodeAbsoluteFormScenario()).toEqual({
unauthorized: { status: 407, body: "" },
forwarded: { status: 200, body: "TARGET GET /absolute?q=1" },
posted: { status: 200, body: "TARGET POST /post request body" },
chunked: { status: 200, body: "TARGET CHUNKED" },
held: { status: 200, body: "ok" },
opened: 4,
destinationsCorrect: true,
directConnections: 0,
heldDestroyed: true,
})
})
test("limits concurrent tunnel setup to six connections", async () => {
let active = 0
let maximum = 0
let started = 0
const releases: Array<() => void> = []
const proxy = await createBrowserProxy({
connect: (_destination, signal) =>
new Promise<Duplex>((resolve, reject) => {
active++
started++
maximum = Math.max(maximum, active)
const finish = (result: () => void) => {
signal.removeEventListener("abort", abort)
active--
result()
}
const abort = () => finish(() => reject(new Error("cancelled")))
signal.addEventListener("abort", abort, { once: true })
releases.push(() =>
finish(() =>
resolve(
new Duplex({
read() {},
write(_chunk, _encoding, callback) {
callback()
},
}),
),
),
)
}),
})
const connections = Array.from({ length: 7 }, () =>
proxyConnect(proxy, "target.example:443").then(
(result) => result.socket.destroy(),
() => undefined,
),
)
try {
await waitFor(() => started === 6)
expect(maximum).toBe(6)
expect(started).toBe(6)
releases.shift()?.()
await waitFor(() => started === 7)
expect(maximum).toBe(6)
} finally {
await proxy.close()
await Promise.all(connections)
}
})
test("aborts pending CONNECT setup when the downstream client closes", async () => {
let aborted = false
let resolveStarted!: () => void
const started = new Promise<void>((resolve) => {
resolveStarted = resolve
})
const proxy = await createBrowserProxy({
connect: (_destination, signal) =>
new Promise<Duplex>((_resolve, reject) => {
resolveStarted()
const abort = () => {
aborted = true
reject(new Error("cancelled"))
}
signal.addEventListener("abort", abort, { once: true })
if (signal.aborted) abort()
}),
})
const socket = proxyClient(
proxy,
`CONNECT target.example:443 HTTP/1.1\r\nHost: target.example:443\r\nProxy-Authorization: ${authorization(proxy)}\r\n\r\n`,
)
try {
await started
socket.destroy()
await waitFor(() => aborted)
} finally {
socket.destroy()
await proxy.close()
}
})
test("aborts pending absolute-form setup when the downstream client closes", async () => {
let aborted = false
let resolveStarted!: () => void
const started = new Promise<void>((resolve) => {
resolveStarted = resolve
})
const proxy = await createBrowserProxy({
connect: (_destination, signal) =>
new Promise<Duplex>((_resolve, reject) => {
resolveStarted()
const abort = () => {
aborted = true
reject(new Error("cancelled"))
}
signal.addEventListener("abort", abort, { once: true })
if (signal.aborted) abort()
}),
})
const socket = proxyClient(
proxy,
`GET http://target.example/pending HTTP/1.1\r\nHost: target.example\r\nProxy-Authorization: ${authorization(proxy)}\r\n\r\n`,
)
try {
await started
socket.destroy()
await waitFor(() => aborted)
} finally {
socket.destroy()
await proxy.close()
}
})
test("closes absolute-form requests when the target tunnel cannot connect", async () => {
const proxy = await createBrowserProxy({ connect: () => Promise.reject(new Error("target unavailable")) })
const socket = proxyClient(
proxy,
`GET http://target.example/unavailable HTTP/1.1\r\nHost: target.example\r\nProxy-Authorization: ${authorization(proxy)}\r\n\r\n`,
)
const chunks: Buffer[] = []
socket.on("data", (chunk) => chunks.push(chunk))
try {
await once(socket, "close")
expect(Buffer.concat(chunks).toString()).not.toContain("502 Bad Gateway")
} finally {
socket.destroy()
await proxy.close()
}
})
})
type ProxyInfo = Awaited<ReturnType<typeof createBrowserProxy>>
function authorization(proxy: ProxyInfo) {
return `Basic ${Buffer.from(`${proxy.credentials.username}:${proxy.credentials.password}`).toString("base64")}`
}
function proxyClient(proxy: ProxyInfo, request: string) {
const socket = connect(
{
host: "127.0.0.1",
port: proxy.port,
servername: proxy.host.endsWith(".localhost") ? proxy.host : undefined,
rejectUnauthorized: false,
},
() => socket.write(request),
)
socket.on("error", () => undefined)
return socket
}
async function nodeAbsoluteFormScenario() {
const directory = await mkdtemp(join(tmpdir(), "opencode-browser-proxy-"))
try {
const built = await Bun.build({
entrypoints: [join(import.meta.dir, "../../src/node/browser/proxy.ts")],
outdir: directory,
naming: "browser-proxy.mjs",
target: "node",
format: "esm",
})
if (!built.success) throw new Error(built.logs.map((log) => log.message).join("\n"))
const output = built.outputs[0]
if (!output) throw new Error("Browser proxy Node bundle was not emitted")
const child = Bun.spawn(
["node", "--input-type=module", "-e", absoluteFormScenario(pathToFileURL(output.path).href)],
{ stdout: "pipe", stderr: "pipe" },
)
const [code, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
if (code !== 0) throw new Error(stderr || stdout)
const result: unknown = JSON.parse(stdout)
return result
} finally {
await rm(directory, { recursive: true, force: true })
}
}
function absoluteFormScenario(moduleURL: string) {
return `import { createBrowserProxy } from ${JSON.stringify(moduleURL)}
import { once } from "node:events"
import { Agent, createServer, request } from "node:http"
import { Socket } from "node:net"
import { Duplex } from "node:stream"
import { connect } from "node:tls"
const proxyRequest = async (proxy, path, authenticated, options = {}) => {
const socket = await new Promise((resolve, reject) => {
const socket = connect({ host: "127.0.0.1", port: proxy.port, servername: proxy.host, rejectUnauthorized: false }, () => resolve(socket))
socket.once("error", reject)
})
const agent = new Agent({ keepAlive: false, maxSockets: 1, noDelay: false })
agent.createConnection = () => socket
const body = Buffer.from(options.body ?? "")
try {
return await new Promise((resolve, reject) => {
const incoming = request({
agent,
hostname: proxy.host,
path,
method: options.method ?? "GET",
headers: {
connection: "close",
...(authenticated ? { "proxy-authorization": "Basic " + Buffer.from(proxy.credentials.username + ":" + proxy.credentials.password).toString("base64") } : {}),
...(options.chunked ? { "transfer-encoding": "chunked" } : { "content-length": body.byteLength.toString() }),
},
}, (response) => {
const chunks = []
response.on("data", (chunk) => chunks.push(chunk))
response.once("end", () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString() }))
})
incoming.once("error", reject)
incoming.end(body)
})
} finally {
agent.destroy()
socket.destroy()
}
}
let directConnections = 0
const decoy = createServer((_incoming, response) => response.end("DIRECT DIAL"))
decoy.on("connection", () => directConnections++)
decoy.listen(0, "127.0.0.1")
await once(decoy, "listening")
const decoyAddress = decoy.address()
if (!decoyAddress || typeof decoyAddress === "string") throw new Error("decoy server did not bind TCP")
const targetServer = createServer((incoming, response) => {
const chunks = []
incoming.on("data", (chunk) => chunks.push(chunk))
incoming.on("end", () => {
response.writeHead(200, { "content-type": "text/plain" })
if (incoming.url === "/chunked") {
response.write("TARGET ")
response.end("CHUNKED")
return
}
const body = Buffer.concat(chunks).toString()
response.end("TARGET " + incoming.method + " " + incoming.url + (body ? " " + body : ""))
})
})
targetServer.listen(0, "127.0.0.1")
await once(targetServer, "listening")
const targetAddress = targetServer.address()
if (!targetAddress || typeof targetAddress === "string") throw new Error("target server did not bind TCP")
const opened = []
let held
const proxy = await createBrowserProxy({
connect: async (destination, signal) => {
opened.push(destination)
if (destination.host === "held.example") {
let responded = false
held = new Duplex({
read() {},
write(chunk, _encoding, callback) {
if (!responded && Buffer.from(chunk).includes(Buffer.from("\\r\\n\\r\\n"))) {
responded = true
this.push(Buffer.from("HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\n\\r\\nok"))
}
callback()
},
})
return held
}
const socket = new Socket({ allowHalfOpen: true })
const abort = () => socket.destroy(new Error("cancelled"))
signal.addEventListener("abort", abort, { once: true })
socket.once("close", () => signal.removeEventListener("abort", abort))
socket.connect(targetAddress.port, "127.0.0.1")
await once(socket, "connect")
return socket
},
})
try {
const base = "http://127.0.0.1:" + decoyAddress.port
const unauthorized = await proxyRequest(proxy, base + "/private", false)
const forwarded = await proxyRequest(proxy, base + "/absolute?q=1", true)
const posted = await proxyRequest(proxy, base + "/post", true, { method: "POST", body: "request body", chunked: true })
const chunked = await proxyRequest(proxy, base + "/chunked", true)
const heldResult = await proxyRequest(proxy, "http://held.example/held-open", true)
await new Promise((resolve) => setImmediate(resolve))
console.log(JSON.stringify({
unauthorized,
forwarded,
posted,
chunked,
held: heldResult,
opened: opened.length,
destinationsCorrect: opened.slice(0, 3).every((item) => item.host === "127.0.0.1" && item.port === decoyAddress.port) && opened[3]?.host === "held.example" && opened[3]?.port === 80,
directConnections,
heldDestroyed: held?.destroyed === true,
}))
} finally {
await proxy.close()
targetServer.closeAllConnections()
decoy.closeAllConnections()
await Promise.all([new Promise((resolve) => targetServer.close(resolve)), new Promise((resolve) => decoy.close(resolve))])
}`
}
function proxyConnect(proxy: ProxyInfo, destination: string) {
return new Promise<{ status: number; socket: TLSSocket; leftover: Buffer }>((resolve, reject) => {
const socket = connect(
{
host: "127.0.0.1",
port: proxy.port,
servername: proxy.host.endsWith(".localhost") ? proxy.host : undefined,
rejectUnauthorized: false,
},
() => {
socket.write(
`CONNECT ${destination} HTTP/1.1\r\nHost: ${destination}\r\nProxy-Authorization: ${authorization(proxy)}\r\n\r\n`,
)
},
)
let buffered = Buffer.alloc(0)
let settled = false
const onData = (chunk: Buffer) => {
buffered = Buffer.concat([buffered, chunk])
const end = buffered.indexOf("\r\n\r\n")
if (end < 0) return
socket.off("data", onData)
const status = Number(buffered.toString("ascii", 0, end).split(" ", 2)[1])
settled = true
resolve({ status, socket, leftover: buffered.subarray(end + 4) })
}
socket.on("data", onData)
socket.once("error", reject)
socket.once("close", () => {
if (!settled) reject(new Error("Browser proxy connection closed before responding"))
})
})
}
function readAll(socket: TLSSocket, initial: Buffer) {
return new Promise<string>((resolve, reject) => {
const chunks = [initial]
socket.on("data", (chunk) => chunks.push(chunk))
socket.on("end", () => resolve(Buffer.concat(chunks).toString()))
socket.once("error", reject)
})
}
function peerCertificate(proxy: ProxyInfo) {
return new Promise<Buffer>((resolve, reject) => {
const socket = connect(
{
host: "127.0.0.1",
port: proxy.port,
servername: proxy.host.endsWith(".localhost") ? proxy.host : undefined,
rejectUnauthorized: false,
},
() => {
resolve(socket.getPeerCertificate().raw)
socket.end()
},
)
socket.once("error", reject)
})
}
async function waitFor(check: () => boolean) {
for (let attempt = 0; attempt < 100; attempt++) {
if (check()) return
await Bun.sleep(5)
}
throw new Error("Timed out waiting for browser proxy test condition")
}