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 }); }