Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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."
Expand Down
9 changes: 5 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion npm/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
144 changes: 137 additions & 7 deletions npm/scripts/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, "..");
Expand All @@ -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;
Expand All @@ -25,14 +115,16 @@ 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) {
fail(new Error(`too many redirects: ${url}`));
return;
}
requestBuffer(new URL(res.headers.location, url).toString(), {
env,
get,
maxBytes,
maxRedirects: maxRedirects - 1,
Expand All @@ -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);
Expand All @@ -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}`)));
});
}

Expand Down Expand Up @@ -226,12 +353,15 @@ if (require.main === module) {
}

module.exports = {
buildConnectRequest,
connectTunnel,
embeddedChecksum,
findChecksum,
install,
logInstallHints,
mcpConfigForTarget,
parseChecksumLine,
proxyForUrl,
requestBuffer,
resolveWindowsArch,
sha256,
Expand Down
52 changes: 52 additions & 0 deletions npm/scripts/install.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -93,20 +96,23 @@ async function run() {

await assert.rejects(
requestBuffer("https://example.test/loop", {
env: {},
get: fakeGet(() => ({ statusCode: 302, headers: { location: "/loop" } })),
maxRedirects: 2,
}),
/too many redirects/
);
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,
}),
/download too large: 5 bytes/
);
await assert.rejects(
requestBuffer("https://example.test/large-body", {
env: {},
get: fakeGet({ "https://example.test/large-body": { body: Buffer.alloc(5) } }),
maxBytes: 4,
}),
Expand Down Expand Up @@ -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 });
}
Expand Down