From e9bf7877d6d4bba0f67c1094fe731998d9b41360 Mon Sep 17 00:00:00 2001 From: Delicious233 <101502465+DeliciousBuding@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:21:21 +0800 Subject: [PATCH] feat(npm): installer honors HTTPS_PROXY via CONNECT tunnel (v1.11.1) npm postinstall downloads release binaries from GitHub with raw node https, which ignores proxy environment variables. On machines that can only reach GitHub through an HTTP proxy (corporate egress, Clash mixed port), `npm i -g` failed with ETIMEDOUT during postinstall. - requestBuffer now resolves HTTPS_PROXY/https_proxy/ALL_PROXY with NO_PROXY exclusions and tunnels https downloads through the proxy with an HTTP CONNECT handshake before starting TLS. - Pure helpers (proxyForUrl, buildConnectRequest) plus a local fake proxy rejecting CONNECT are covered by install.test.js. - Version bump 1.11.1 (Cargo.toml, npm/package.json, Cargo.lock); no Rust behavior changes. Co-authored-by: Cursor --- CHANGELOG.md | 10 +++ Cargo.lock | 2 +- Cargo.toml | 2 +- ROADMAP.md | 9 ++- npm/package.json | 2 +- npm/scripts/install.js | 144 ++++++++++++++++++++++++++++++++++-- npm/scripts/install.test.js | 52 +++++++++++++ 7 files changed, 207 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 136d468..2ec9531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [1.11.1] - 2026-08-19 + +### Added + +- npm installer now honors `HTTPS_PROXY` / `https_proxy` (with `ALL_PROXY` fallback and `NO_PROXY` exclusions) when downloading release binaries, tunneling through the proxy with HTTP CONNECT. Fixes `npm i -g` postinstall failures on machines that can only reach GitHub through a proxy. + +### Notes + +- No Rust code changes; the binary rebuild only carries the version bump. + ## [1.11.0] - 2026-08-19 ### Added diff --git a/Cargo.lock b/Cargo.lock index 2dc9a2e..0a50e95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -210,7 +210,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "codex-browser-bridge" -version = "1.11.0" +version = "1.11.1" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index edd99d1..5e64a83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-browser-bridge" -version = "1.11.0" +version = "1.11.1" edition = "2021" license = "MIT" description = "MCP server that exposes Codex Desktop's Chrome browser bridge." diff --git a/ROADMAP.md b/ROADMAP.md index 13cdec0..59c3265 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,15 +1,16 @@ # ROADMAP -## Status: v1.11.0 shipped (2026-08-19) +## Status: v1.11.1 shipped (2026-08-19) 52 MCP tools, dual-era MCP protocol support (`2024-11-05` … `2026-07-28`), tool behavior annotations, CDP event architecture, structured network monitoring, `--mode doctor` CLI, JPEG/WebP screenshots. See [CHANGELOG.md](CHANGELOG.md) for the full release history. **Upstream branding:** OpenAI folded Codex Desktop into the ChatGPT desktop app and renamed the extension to the ChatGPT extension (Chrome + Edge since app build 26.730). The `codex-browser-use` pipe name and protocol are unchanged, so no bridge-side migration was needed; docs now reference both names. -**Architecture health (SUPER):** S 5, U 5, P 5, E 3 (Windows-only), R 4 = **22/25**. Remaining gaps are operational maturity (winget/scoop) and platform reach, not architecture. +**Architecture health (SUPER):** S 5, U 5, P 5, E 3 (Windows-only), R 4 = **22/25**. Remaining gap is platform reach (macOS/Linux transport), not architecture. ### Completed releases +- **v1.11.1** (2026-08-19): npm installer proxy support (`HTTPS_PROXY`/`NO_PROXY` via HTTP CONNECT) — fixes `npm i -g` behind proxies. - **v1.11.0** (2026-08-19): MCP `2026-07-28` dual-era server (`server/discover`, stateless `_meta`-versioned requests, `resultType` + `ttlMs`/`cacheScope` cache hints, `-32022` version errors) with byte-compatible legacy era; MCP tool annotations on all 52 tools; `initialize` version negotiation + `instructions`. - **v1.10.1** (2026-07-14): `codex_evaluate` awaits Promises and surfaces JS exceptions; docs for Promise/exception behavior. - **v1.10.0** (2026-07-10): engineering hardening — reconnect, supply-chain CI, benchmarks, release contract, bounded MCP surfaces. @@ -40,8 +41,8 @@ The tool layer is saturated. The honest gaps are runtime robustness, supply chai ### P2 — Distribution & protocol depth -- [ ] **winget + scoop manifests.** `winget install codex-browser-bridge` is more native than npm for Windows users. Discovery lift, no code. - - Effort: S +- [x] **winget + scoop manifests.** Decision (2026-08-19): **deferred — npm is sufficient.** The target audience (MCP clients: Claude Code / Cursor / agent users) all have Node installed, the npm channel already delivers checksummed binaries with provenance, and winget/scoop would add manifest-repo + per-release bump automation for marginal discoverability. Revisit if user demand materializes; cheapest path then is a winget `portable` manifest auto-PR from release tags. +- [x] **Installer proxy support.** ✅ Done (v1.11.1). `install.js` honors `HTTPS_PROXY`/`NO_PROXY` via an HTTP CONNECT tunnel, so `npm i -g` works behind corporate/Clash-style proxies. - [x] **MCP resources/prompts.** ✅ Done. `resources/list` + `resources/read` expose `codex://tabs` (snapshot via getTabs). `prompts/list` + `prompts/get` ship `login` and `extract-table` workflow templates (each cites the concrete tools to call). `initialize` advertises `resources` + `prompts` capabilities. Subscribe / list-changes omitted — these are on-demand snapshots, not a live feed. 5 tests under `cfg(not(windows))`. - Effort: M · landed in `src/mcp/mod.rs` - [x] **Config file** (`.codex-browser-bridge.toml`) for profile + upload_base. ✅ Done. `src/config.rs` reads `CODEX_BRIDGE_CONFIG` env path or `./.codex-browser-bridge.toml`; precedence CLI flags > config > env > default. Malformed file warns + is ignored (never bricks startup). diff --git a/npm/package.json b/npm/package.json index 1089aa1..cc91d29 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "@delicious233/codex-browser-bridge", - "version": "1.11.0", + "version": "1.11.1", "private": false, "description": "MCP server that exposes Codex Desktop's Chrome browser bridge for Claude Code and other agents.", "bin": { diff --git a/npm/scripts/install.js b/npm/scripts/install.js index feda50b..54c2427 100644 --- a/npm/scripts/install.js +++ b/npm/scripts/install.js @@ -4,6 +4,8 @@ const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); const https = require("https"); +const net = require("net"); +const tls = require("tls"); const defaultRepo = "DeliciousBuding/codex-browser-bridge"; const packageRoot = path.join(__dirname, ".."); @@ -13,10 +15,98 @@ const DEFAULT_DOWNLOAD_TIMEOUT_MS = 60000; const DEFAULT_MAX_REDIRECTS = 5; const DEFAULT_MAX_BYTES = 64 * 1024 * 1024; +/// Resolve the HTTP proxy to tunnel an https:// download through. +/// Honors HTTPS_PROXY/https_proxy (plus ALL_PROXY as a fallback) and the +/// NO_PROXY exclusion list. Only plain `http://` proxies are supported — +/// CONNECT through a TLS-terminating proxy is out of scope for an installer. +function proxyForUrl(urlStr, env = process.env) { + let parsed; + try { + parsed = new URL(urlStr); + } catch { + return null; + } + if (parsed.protocol !== "https:") return null; + + const noProxy = env.NO_PROXY || env.no_proxy; + if (noProxy) { + const host = parsed.hostname.toLowerCase(); + const entries = noProxy + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); + for (const entry of entries) { + if (entry === "*" || host === entry || host.endsWith(entry.replace(/^\./, ""))) { + return null; + } + } + } + + const raw = env.HTTPS_PROXY || env.https_proxy || env.ALL_PROXY || env.all_proxy; + if (!raw) return null; + let proxy; + try { + proxy = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`); + } catch { + return null; + } + if (proxy.protocol !== "http:") return null; + const port = Number(proxy.port || 80); + if (!Number.isInteger(port) || port <= 0 || port > 65535) return null; + return { host: proxy.hostname, port }; +} + +function buildConnectRequest(host, port) { + return `CONNECT ${host}:${port} HTTP/1.1\r\nHost: ${host}:${port}\r\nProxy-Connection: keep-alive\r\n\r\n`; +} + +/// Open an HTTP CONNECT tunnel to `host:port` through `proxy`. Resolves with +/// the raw socket once the proxy answers 200, plus any bytes the proxy already +/// forwarded after the response header (must be unshifted before TLS starts). +function connectTunnel(proxy, host, port, timeoutMs) { + return new Promise((resolve, reject) => { + const socket = net.connect(proxy.port, proxy.host); + let settled = false; + const fail = (err) => { + if (settled) return; + settled = true; + socket.destroy(); + reject(err); + }; + socket.setTimeout(timeoutMs, () => fail(new Error(`timeout connecting to proxy ${proxy.host}:${proxy.port}`))); + socket.once("error", fail); + socket.once("connect", () => { + socket.write(buildConnectRequest(host, port)); + }); + let buffer = ""; + const onData = (chunk) => { + buffer += chunk.toString("latin1"); + const headerEnd = buffer.indexOf("\r\n\r\n"); + if (headerEnd === -1) { + if (buffer.length > 16 * 1024) fail(new Error("proxy CONNECT response headers too large")); + return; + } + socket.removeListener("data", onData); + socket.setTimeout(0); + const statusLine = buffer.slice(0, buffer.indexOf("\r\n")); + const status = statusLine.match(/^HTTP\/1\.[01] (\d{3})/); + if (!status || status[1] !== "200") { + fail(new Error(`proxy CONNECT rejected: ${statusLine.trim()}`)); + return; + } + if (settled) return; + settled = true; + resolve({ socket, leftover: Buffer.from(buffer.slice(headerEnd + 4), "latin1") }); + }; + socket.on("data", onData); + }); +} + function requestBuffer(url, options = {}) { const maxBytes = options.maxBytes || DEFAULT_MAX_BYTES; const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS; const timeoutMs = options.timeoutMs || DEFAULT_DOWNLOAD_TIMEOUT_MS; + const env = options.env || process.env; const get = options.get || https.get; return new Promise((resolve, reject) => { let settled = false; @@ -25,7 +115,8 @@ function requestBuffer(url, options = {}) { settled = true; reject(err); }; - const req = get(url, { headers: { "User-Agent": "codex-browser-bridge-npm" }, timeout: timeoutMs }, (res) => { + const headers = { "User-Agent": "codex-browser-bridge-npm" }; + const onResponse = (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); if (maxRedirects <= 0) { @@ -33,6 +124,7 @@ function requestBuffer(url, options = {}) { return; } requestBuffer(new URL(res.headers.location, url).toString(), { + env, get, maxBytes, maxRedirects: maxRedirects - 1, @@ -56,7 +148,7 @@ function requestBuffer(url, options = {}) { res.on("data", (c) => { total += c.length; if (total > maxBytes) { - req.destroy(new Error(`download too large: exceeded ${maxBytes} bytes`)); + res.destroy(new Error(`download too large: exceeded ${maxBytes} bytes`)); return; } chunks.push(c); @@ -68,11 +160,46 @@ function requestBuffer(url, options = {}) { } }); res.on("error", fail); - }).on("error", fail); - req.on("timeout", () => { - req.destroy(); - fail(new Error(`timeout: ${url}`)); - }); + }; + const startDirect = () => { + const req = get(url, { headers, timeout: timeoutMs }, onResponse).on("error", fail); + req.on("timeout", () => { + req.destroy(); + fail(new Error(`timeout: ${url}`)); + }); + }; + + // proxyForUrl only returns a proxy for parseable https URLs, so a + // non-null result guarantees `new URL(url)` succeeds below. + const proxy = proxyForUrl(url, env); + if (!proxy) { + startDirect(); + return; + } + const parsed = new URL(url); + connectTunnel(proxy, parsed.hostname, 443, timeoutMs) + .then(({ socket, leftover }) => { + if (leftover.length > 0) socket.unshift(leftover); + const tlsSocket = tls.connect({ socket, servername: parsed.hostname }); + const req = https.request( + { + host: parsed.hostname, + port: 443, + path: `${parsed.pathname}${parsed.search}`, + headers, + timeout: timeoutMs, + createConnection: () => tlsSocket, + }, + onResponse + ); + req.on("error", fail); + req.on("timeout", () => { + req.destroy(); + fail(new Error(`timeout: ${url}`)); + }); + req.end(); + }) + .catch((err) => fail(new Error(`proxy tunnel failed via ${proxy.host}:${proxy.port}: ${err.message}`))); }); } @@ -226,12 +353,15 @@ if (require.main === module) { } module.exports = { + buildConnectRequest, + connectTunnel, embeddedChecksum, findChecksum, install, logInstallHints, mcpConfigForTarget, parseChecksumLine, + proxyForUrl, requestBuffer, resolveWindowsArch, sha256, diff --git a/npm/scripts/install.test.js b/npm/scripts/install.test.js index e543856..f96f605 100644 --- a/npm/scripts/install.test.js +++ b/npm/scripts/install.test.js @@ -2,15 +2,18 @@ const assert = require("assert"); const { EventEmitter } = require("events"); const { spawnSync } = require("child_process"); const fs = require("fs"); +const net = require("net"); const path = require("path"); const { PassThrough } = require("stream"); const { + buildConnectRequest, embeddedChecksum, findChecksum, install, logInstallHints, mcpConfigForTarget, parseChecksumLine, + proxyForUrl, requestBuffer, resolveWindowsArch, sha256, @@ -93,6 +96,7 @@ async function run() { await assert.rejects( requestBuffer("https://example.test/loop", { + env: {}, get: fakeGet(() => ({ statusCode: 302, headers: { location: "/loop" } })), maxRedirects: 2, }), @@ -100,6 +104,7 @@ async function run() { ); await assert.rejects( requestBuffer("https://example.test/large-header", { + env: {}, get: fakeGet({ "https://example.test/large-header": { headers: { "content-length": "5" }, body: Buffer.alloc(1) } }), maxBytes: 4, }), @@ -107,6 +112,7 @@ async function run() { ); await assert.rejects( requestBuffer("https://example.test/large-body", { + env: {}, get: fakeGet({ "https://example.test/large-body": { body: Buffer.alloc(5) } }), maxBytes: 4, }), @@ -292,6 +298,52 @@ async function run() { }), /checksum mismatch/ ); + + // ── Proxy support ── + assert.strictEqual(proxyForUrl("https://github.com/x", {}), null); + assert.strictEqual(proxyForUrl("http://github.com/x", { HTTPS_PROXY: "http://127.0.0.1:7897" }), null); + assert.deepStrictEqual(proxyForUrl("https://github.com/x", { HTTPS_PROXY: "http://127.0.0.1:7897" }), { + host: "127.0.0.1", + port: 7897, + }); + assert.deepStrictEqual(proxyForUrl("https://github.com/x", { https_proxy: "127.0.0.1:7897" }), { + host: "127.0.0.1", + port: 7897, + }); + assert.deepStrictEqual(proxyForUrl("https://github.com/x", { ALL_PROXY: "http://proxy.local:8080" }), { + host: "proxy.local", + port: 8080, + }); + assert.strictEqual(proxyForUrl("https://github.com/x", { HTTPS_PROXY: "http://127.0.0.1:7897", NO_PROXY: "*" }), null); + assert.strictEqual(proxyForUrl("https://api.github.com/x", { HTTPS_PROXY: "http://127.0.0.1:7897", NO_PROXY: ".github.com" }), null); + assert.deepStrictEqual( + proxyForUrl("https://objects.githubusercontent.com/x", { HTTPS_PROXY: "http://127.0.0.1:7897", NO_PROXY: ".github.com" }), + { host: "127.0.0.1", port: 7897 } + ); + // TLS-terminating proxies are out of scope for the installer. + assert.strictEqual(proxyForUrl("https://github.com/x", { HTTPS_PROXY: "https://127.0.0.1:7897" }), null); + + assert.strictEqual( + buildConnectRequest("github.com", 443), + "CONNECT github.com:443 HTTP/1.1\r\nHost: github.com:443\r\nProxy-Connection: keep-alive\r\n\r\n" + ); + + // A proxy that rejects CONNECT must surface as a download error, not a hang. + const rejectingProxy = net.createServer((socket) => { + socket.once("data", () => socket.write("HTTP/1.1 403 Forbidden\r\n\r\n")); + }); + await new Promise((resolve) => rejectingProxy.listen(0, "127.0.0.1", resolve)); + try { + await assert.rejects( + requestBuffer("https://github.com/example/asset", { + env: { HTTPS_PROXY: `http://127.0.0.1:${rejectingProxy.address().port}` }, + timeoutMs: 5000, + }), + /CONNECT rejected/ + ); + } finally { + rejectingProxy.close(); + } } finally { fs.rmSync(tmp, { recursive: true, force: true }); }