From 1446dae59b370faee3b8b1710ae14069c950cf42 Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:49:25 +0100 Subject: [PATCH 01/29] feat(config): add ProxyConfig schema Add Zod schema for proxy configuration with: - http: HTTP proxy URL - https: HTTPS proxy URL - no_proxy: Array of hosts/patterns to bypass Uses .string() instead of .string().url() for flexibility (supports formats like proxy:8080 without schema). Part of proxy support feature to work around Bun's fetch() ignoring HTTP_PROXY/HTTPS_PROXY environment variables. --- packages/opencode/src/config/config.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6dd0592d51e3..68edd5d5e92e 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -999,6 +999,21 @@ export namespace Config { }) export type Provider = z.infer + export const ProxyConfig = z + .object({ + http: z.string().optional().describe("HTTP proxy URL (e.g., http://proxy:8080)"), + https: z.string().optional().describe("HTTPS proxy URL (e.g., http://proxy:8080)"), + no_proxy: z + .array(z.string()) + .optional() + .describe("Hosts or patterns to bypass proxy (e.g., localhost, *.internal.com)"), + }) + .strict() + .meta({ + ref: "ProxyConfig", + }) + export type ProxyConfig = z.infer + export const Info = z .object({ $schema: z.string().optional().describe("JSON schema reference for configuration validation"), @@ -1097,6 +1112,9 @@ export namespace Config { ) .optional() .describe("MCP (Model Context Protocol) server configurations"), + proxy: ProxyConfig.optional().describe( + "HTTP/HTTPS proxy configuration. Overrides HTTP_PROXY, HTTPS_PROXY, and NO_PROXY environment variables.", + ), formatter: z .union([ z.literal(false), From 9a704ea61bed2a75d77301b5c7da32b8b64ac2bc Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:50:20 +0100 Subject: [PATCH 02/29] feat(util): add proxy-aware fetch wrapper Create proxyFetch wrapper that explicitly passes proxy to Bun's fetch() since Bun ignores HTTP_PROXY/HTTPS_PROXY environment variables. Exports: - proxyFetch(input, init) - Main fetch wrapper - getProxyConfig() - Read config/env proxy settings - shouldBypassProxy(hostname, noProxy) - Check NO_PROXY patterns - getProxyForUrl(url) - Resolve proxy for a URL - setProxyConfig(config) - Init from opencode.json config Features: - Config file takes precedence over environment variables - Supports NO_PROXY patterns: *, hostname, *.domain, .domain - Feature flag OPENCODE_DISABLE_PROXY to bypass proxy - Per-request proxy override or disable via init.proxy This is the SPOF (single point of failure) for all proxy support. --- packages/opencode/src/util/fetch.ts | 158 ++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 packages/opencode/src/util/fetch.ts diff --git a/packages/opencode/src/util/fetch.ts b/packages/opencode/src/util/fetch.ts new file mode 100644 index 000000000000..fce3f020a66f --- /dev/null +++ b/packages/opencode/src/util/fetch.ts @@ -0,0 +1,158 @@ +/** + * Proxy-aware fetch wrapper for Bun + * + * Bun's native fetch() ignores HTTP_PROXY/HTTPS_PROXY environment variables. + * This wrapper explicitly passes the proxy via Bun's { proxy: url } option. + * + * @see https://bun.com/docs/guides/http/proxy + */ + +import type { Config } from "../config/config" + +// Bun's fetch supports proxy option but TypeScript types don't include it +type BunFetchInit = RequestInit & { + proxy?: string | { url: string; headers?: Record } +} + +/** + * Internal proxy configuration state + * Set via setProxyConfig() from opencode.json config + */ +let _configProxy: Config.ProxyConfig | undefined + +/** + * Set proxy configuration from opencode.json + * Config values take precedence over environment variables + */ +export function setProxyConfig(config: Config.ProxyConfig | undefined): void { + _configProxy = config +} + +/** + * Get current proxy configuration + * Priority: config file > environment variables + */ +export function getProxyConfig(): { + http?: string + https?: string + noProxy: string[] +} { + return { + http: _configProxy?.http || process.env.HTTP_PROXY || process.env.http_proxy, + https: _configProxy?.https || process.env.HTTPS_PROXY || process.env.https_proxy, + noProxy: + _configProxy?.no_proxy || + (process.env.NO_PROXY || process.env.no_proxy || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + } +} + +/** + * Check if a hostname should bypass the proxy based on NO_PROXY patterns + * + * Supported patterns: + * - "*" - bypass all + * - "hostname" - exact match + * - "*.domain.com" - wildcard suffix + * - ".domain.com" - suffix match + * - "domain.com" - also matches *.domain.com + */ +export function shouldBypassProxy(hostname: string, noProxy: string[]): boolean { + hostname = hostname.toLowerCase() + + for (const pattern of noProxy) { + const p = pattern.toLowerCase().trim() + + // Match all + if (p === "*") return true + + // Exact match + if (hostname === p) return true + + // Wildcard: *.example.com + if (p.startsWith("*.") && hostname.endsWith(p.slice(1))) return true + + // Suffix: .example.com + if (p.startsWith(".") && hostname.endsWith(p)) return true + + // Domain suffix without dot (e.g., "example.com" matches "sub.example.com") + if (hostname.endsWith("." + p)) return true + } + + return false +} + +/** + * Get the proxy URL to use for a given URL + * Returns undefined if no proxy should be used (NO_PROXY match or no proxy configured) + */ +export function getProxyForUrl(url: string | URL): string | undefined { + // Feature flag to disable proxy entirely + if (process.env.OPENCODE_DISABLE_PROXY) { + return undefined + } + + const urlObj = typeof url === "string" ? new URL(url) : url + const config = getProxyConfig() + + if (shouldBypassProxy(urlObj.hostname, config.noProxy)) { + return undefined + } + + return urlObj.protocol === "https:" ? config.https : config.http +} + +/** + * Proxy-aware fetch wrapper + * + * Usage: + * ```ts + * import { proxyFetch } from "./util/fetch" + * + * // Auto-detect proxy from config/env + * const response = await proxyFetch("https://api.example.com") + * + * // Explicitly disable proxy for this request + * const response = await proxyFetch("https://localhost:3000", { proxy: false }) + * + * // Explicitly set proxy for this request + * const response = await proxyFetch("https://api.example.com", { + * proxy: "http://other-proxy:8080" + * }) + * ``` + */ +export async function proxyFetch( + input: RequestInfo | URL, + init?: BunFetchInit & { proxy?: string | false | { url: string; headers?: Record } }, +): Promise { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + + // Proxy explicitly disabled for this request + if (init?.proxy === false) { + const { proxy: _, ...rest } = init + return fetch(input, rest) + } + + // Proxy explicitly provided for this request + if (init?.proxy) { + return fetch(input, init as RequestInit) + } + + // Auto-detect proxy from config/env + const proxyUrl = getProxyForUrl(url) + + if (proxyUrl) { + return fetch(input, { + ...init, + proxy: proxyUrl, + } as RequestInit) + } + + return fetch(input, init) +} + +// Named exports +export { proxyFetch as fetch } +export default proxyFetch From fb6fd110847e366ef82abe16e1099ac905842f60 Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:51:24 +0100 Subject: [PATCH 03/29] test(fetch): add unit tests for proxy wrapper Test coverage for: - getProxyConfig(): Reading from env vars (HTTP_PROXY, HTTPS_PROXY, NO_PROXY) - shouldBypassProxy(): Pattern matching (*, exact, *.domain, .domain) - getProxyForUrl(): HTTP vs HTTPS selection, NO_PROXY bypass - Config override: Config file takes precedence over env vars - OPENCODE_DISABLE_PROXY feature flag Tests use bun:test framework with proper env var isolation. --- packages/opencode/test/util/fetch.test.ts | 200 ++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 packages/opencode/test/util/fetch.test.ts diff --git a/packages/opencode/test/util/fetch.test.ts b/packages/opencode/test/util/fetch.test.ts new file mode 100644 index 000000000000..7a711e16a86c --- /dev/null +++ b/packages/opencode/test/util/fetch.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import { + getProxyConfig, + shouldBypassProxy, + getProxyForUrl, + setProxyConfig, +} from "../../src/util/fetch" + +describe("proxy-fetch", () => { + // Save original env vars + const originalEnv = { ...process.env } + + beforeEach(() => { + // Clear proxy-related env vars + delete process.env.HTTP_PROXY + delete process.env.HTTPS_PROXY + delete process.env.NO_PROXY + delete process.env.http_proxy + delete process.env.https_proxy + delete process.env.no_proxy + delete process.env.OPENCODE_DISABLE_PROXY + + // Clear config + setProxyConfig(undefined) + }) + + afterEach(() => { + // Restore original env vars + Object.assign(process.env, originalEnv) + }) + + describe("getProxyConfig", () => { + test("returns empty config when no proxy configured", () => { + const config = getProxyConfig() + expect(config.http).toBeUndefined() + expect(config.https).toBeUndefined() + expect(config.noProxy).toEqual([]) + }) + + test("reads HTTP_PROXY from environment", () => { + process.env.HTTP_PROXY = "http://proxy:8080" + const config = getProxyConfig() + expect(config.http).toBe("http://proxy:8080") + }) + + test("reads http_proxy (lowercase) from environment", () => { + process.env.http_proxy = "http://proxy:8080" + const config = getProxyConfig() + expect(config.http).toBe("http://proxy:8080") + }) + + test("reads HTTPS_PROXY from environment", () => { + process.env.HTTPS_PROXY = "http://proxy:8443" + const config = getProxyConfig() + expect(config.https).toBe("http://proxy:8443") + }) + + test("reads https_proxy (lowercase) from environment", () => { + process.env.https_proxy = "http://proxy:8443" + const config = getProxyConfig() + expect(config.https).toBe("http://proxy:8443") + }) + + test("reads NO_PROXY from environment", () => { + process.env.NO_PROXY = "localhost,127.0.0.1,*.internal.com" + const config = getProxyConfig() + expect(config.noProxy).toEqual(["localhost", "127.0.0.1", "*.internal.com"]) + }) + + test("handles NO_PROXY with spaces", () => { + process.env.NO_PROXY = "localhost, 127.0.0.1 , *.internal.com" + const config = getProxyConfig() + expect(config.noProxy).toEqual(["localhost", "127.0.0.1", "*.internal.com"]) + }) + + test("config overrides environment variables", () => { + process.env.HTTP_PROXY = "http://env-proxy:8080" + process.env.HTTPS_PROXY = "http://env-proxy:8443" + process.env.NO_PROXY = "env.local" + + setProxyConfig({ + http: "http://config-proxy:8080", + https: "http://config-proxy:8443", + no_proxy: ["config.local"], + }) + + const config = getProxyConfig() + expect(config.http).toBe("http://config-proxy:8080") + expect(config.https).toBe("http://config-proxy:8443") + expect(config.noProxy).toEqual(["config.local"]) + }) + + test("partial config uses env for missing values", () => { + process.env.HTTP_PROXY = "http://env-proxy:8080" + process.env.HTTPS_PROXY = "http://env-proxy:8443" + + setProxyConfig({ + http: "http://config-proxy:8080", + // https not set - should fall back to env + }) + + const config = getProxyConfig() + expect(config.http).toBe("http://config-proxy:8080") + expect(config.https).toBe("http://env-proxy:8443") + }) + }) + + describe("shouldBypassProxy", () => { + test("returns false when noProxy is empty", () => { + expect(shouldBypassProxy("example.com", [])).toBe(false) + }) + + test("matches wildcard *", () => { + expect(shouldBypassProxy("any.host.com", ["*"])).toBe(true) + }) + + test("matches exact hostname", () => { + expect(shouldBypassProxy("localhost", ["localhost"])).toBe(true) + expect(shouldBypassProxy("example.com", ["localhost"])).toBe(false) + }) + + test("matches wildcard pattern *.domain.com", () => { + expect(shouldBypassProxy("sub.example.com", ["*.example.com"])).toBe(true) + expect(shouldBypassProxy("deep.sub.example.com", ["*.example.com"])).toBe(true) + expect(shouldBypassProxy("example.com", ["*.example.com"])).toBe(false) + expect(shouldBypassProxy("notexample.com", ["*.example.com"])).toBe(false) + }) + + test("matches suffix pattern .domain.com", () => { + expect(shouldBypassProxy("sub.example.com", [".example.com"])).toBe(true) + expect(shouldBypassProxy("deep.sub.example.com", [".example.com"])).toBe(true) + expect(shouldBypassProxy("example.com", [".example.com"])).toBe(false) + }) + + test("matches domain suffix without dot", () => { + expect(shouldBypassProxy("sub.example.com", ["example.com"])).toBe(true) + expect(shouldBypassProxy("api.example.com", ["example.com"])).toBe(true) + // exact match + expect(shouldBypassProxy("example.com", ["example.com"])).toBe(true) + }) + + test("is case insensitive", () => { + expect(shouldBypassProxy("LOCALHOST", ["localhost"])).toBe(true) + expect(shouldBypassProxy("localhost", ["LOCALHOST"])).toBe(true) + expect(shouldBypassProxy("Sub.Example.COM", ["*.example.com"])).toBe(true) + }) + + test("handles IP addresses", () => { + expect(shouldBypassProxy("127.0.0.1", ["127.0.0.1"])).toBe(true) + expect(shouldBypassProxy("192.168.1.100", ["192.168.1.*"])).toBe(false) // Not a valid pattern + expect(shouldBypassProxy("192.168.1.100", ["192.168.1.100"])).toBe(true) + }) + + test("handles multiple patterns", () => { + const noProxy = ["localhost", "127.0.0.1", "*.internal.com", ".ft.intra"] + expect(shouldBypassProxy("localhost", noProxy)).toBe(true) + expect(shouldBypassProxy("127.0.0.1", noProxy)).toBe(true) + expect(shouldBypassProxy("api.internal.com", noProxy)).toBe(true) + expect(shouldBypassProxy("service.ft.intra", noProxy)).toBe(true) + expect(shouldBypassProxy("external.com", noProxy)).toBe(false) + }) + }) + + describe("getProxyForUrl", () => { + test("returns undefined when no proxy configured", () => { + expect(getProxyForUrl("https://example.com")).toBeUndefined() + }) + + test("returns HTTPS proxy for https URLs", () => { + process.env.HTTPS_PROXY = "http://proxy:8443" + expect(getProxyForUrl("https://example.com")).toBe("http://proxy:8443") + }) + + test("returns HTTP proxy for http URLs", () => { + process.env.HTTP_PROXY = "http://proxy:8080" + expect(getProxyForUrl("http://example.com")).toBe("http://proxy:8080") + }) + + test("accepts URL object", () => { + process.env.HTTPS_PROXY = "http://proxy:8443" + expect(getProxyForUrl(new URL("https://example.com/path"))).toBe("http://proxy:8443") + }) + + test("returns undefined for NO_PROXY hosts", () => { + process.env.HTTPS_PROXY = "http://proxy:8443" + process.env.NO_PROXY = "*.ft.intra,localhost" + + expect(getProxyForUrl("https://external.com")).toBe("http://proxy:8443") + expect(getProxyForUrl("https://alfred.ft.intra/api")).toBeUndefined() + expect(getProxyForUrl("https://localhost:3000")).toBeUndefined() + }) + + test("returns undefined when OPENCODE_DISABLE_PROXY is set", () => { + process.env.HTTPS_PROXY = "http://proxy:8443" + process.env.OPENCODE_DISABLE_PROXY = "1" + + expect(getProxyForUrl("https://example.com")).toBeUndefined() + }) + }) +}) From d6ad2abef14e4251bad613debd21f87218cd8ca7 Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:51:55 +0100 Subject: [PATCH 04/29] refactor(models): use proxyFetch for models.dev Replace native fetch() with proxyFetch() for: - Data() lazy loader (line 98) - refresh() function (line 109) These fetch calls retrieve model definitions from models.dev. --- packages/opencode/src/provider/models.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 07881cbfe224..aae3ddeff663 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -5,6 +5,7 @@ import z from "zod" import { Installation } from "../installation" import { Flag } from "../flag/flag" import { lazy } from "@/util/lazy" +import { proxyFetch } from "@/util/fetch" // Try to import bundled snapshot (generated at build time) // Falls back to undefined in dev mode when snapshot doesn't exist @@ -94,7 +95,7 @@ export namespace ModelsDev { .catch(() => undefined) if (snapshot) return snapshot if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {} - const json = await fetch(`${url()}/api.json`).then((x) => x.text()) + const json = await proxyFetch(`${url()}/api.json`).then((x) => x.text()) return JSON.parse(json) }) @@ -105,7 +106,7 @@ export namespace ModelsDev { export async function refresh() { const file = Bun.file(filepath) - const result = await fetch(`${url()}/api.json`, { + const result = await proxyFetch(`${url()}/api.json`, { headers: { "User-Agent": Installation.USER_AGENT, }, From 78f1a4a4f02c19e2313d4e594bd1ebb988e4e475 Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:52:42 +0100 Subject: [PATCH 05/29] refactor(installation): use proxyFetch for version checks Replace native fetch() with proxyFetch() for: - Homebrew formula API - NPM registry - Chocolatey API - Scoop bucket - GitHub releases API These fetch calls check for available updates. --- packages/opencode/src/installation/index.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index d18c9e31a13b..088fea5f4ac9 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -6,6 +6,7 @@ import { NamedError } from "@opencode-ai/util/error" import { Log } from "../util/log" import { iife } from "@/util/iife" import { Flag } from "../flag/flag" +import { proxyFetch } from "@/util/fetch" declare global { const OPENCODE_VERSION: string @@ -189,7 +190,7 @@ export namespace Installation { if (detectedMethod === "brew") { const formula = await getBrewFormula() if (formula === "opencode") { - return fetch("https://formulae.brew.sh/api/formula/opencode.json") + return proxyFetch("https://formulae.brew.sh/api/formula/opencode.json") .then((res) => { if (!res.ok) throw new Error(res.statusText) return res.json() @@ -205,7 +206,7 @@ export namespace Installation { return reg.endsWith("/") ? reg.slice(0, -1) : reg }) const channel = CHANNEL - return fetch(`${registry}/opencode-ai/${channel}`) + return proxyFetch(`${registry}/opencode-ai/${channel}`) .then((res) => { if (!res.ok) throw new Error(res.statusText) return res.json() @@ -214,7 +215,7 @@ export namespace Installation { } if (detectedMethod === "choco") { - return fetch( + return proxyFetch( "https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version", { headers: { Accept: "application/json;odata=verbose" } }, ) @@ -226,7 +227,7 @@ export namespace Installation { } if (detectedMethod === "scoop") { - return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", { + return proxyFetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", { headers: { Accept: "application/json" }, }) .then((res) => { @@ -236,7 +237,7 @@ export namespace Installation { .then((data: any) => data.version) } - return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest") + return proxyFetch("https://api.github.com/repos/anomalyco/opencode/releases/latest") .then((res) => { if (!res.ok) throw new Error(res.statusText) return res.json() From 410dcd8a025c7692c1d48fbad127eb29bb41bc42 Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:53:46 +0100 Subject: [PATCH 06/29] refactor(tools): use proxyFetch for webfetch/websearch/codesearch Replace native fetch() with proxyFetch() in: - webfetch.ts: Web page fetching tool (2 calls) - websearch.ts: Exa MCP search API (1 call) - codesearch.ts: Exa MCP code search API (1 call) --- packages/opencode/src/tool/codesearch.ts | 3 ++- packages/opencode/src/tool/webfetch.ts | 5 +++-- packages/opencode/src/tool/websearch.ts | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index 28dd4eb4913c..92432542188e 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/packages/opencode/src/tool/codesearch.ts @@ -2,6 +2,7 @@ import z from "zod" import { Tool } from "./tool" import DESCRIPTION from "./codesearch.txt" import { abortAfterAny } from "../util/abort" +import { proxyFetch } from "@/util/fetch" const API_CONFIG = { BASE_URL: "https://mcp.exa.ai", @@ -82,7 +83,7 @@ export const CodeSearchTool = Tool.define("codesearch", { "content-type": "application/json", } - const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTEXT}`, { + const response = await proxyFetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTEXT}`, { method: "POST", headers, body: JSON.stringify(codeRequest), diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index c9479b9df81c..1c7e16519165 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -3,6 +3,7 @@ import { Tool } from "./tool" import TurndownService from "turndown" import DESCRIPTION from "./webfetch.txt" import { abortAfterAny } from "../util/abort" +import { proxyFetch } from "@/util/fetch" const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds @@ -62,12 +63,12 @@ export const WebFetchTool = Tool.define("webfetch", { "Accept-Language": "en-US,en;q=0.9", } - const initial = await fetch(params.url, { signal, headers }) + const initial = await proxyFetch(params.url, { signal, headers }) // Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch) const response = initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge" - ? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } }) + ? await proxyFetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } }) : initial clearTimeout() diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index beedd9c7cb3f..5ec825dc940a 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -2,6 +2,7 @@ import z from "zod" import { Tool } from "./tool" import DESCRIPTION from "./websearch.txt" import { abortAfterAny } from "../util/abort" +import { proxyFetch } from "@/util/fetch" const API_CONFIG = { BASE_URL: "https://mcp.exa.ai", @@ -100,7 +101,7 @@ export const WebSearchTool = Tool.define("websearch", async () => { "content-type": "application/json", } - const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.SEARCH}`, { + const response = await proxyFetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.SEARCH}`, { method: "POST", headers, body: JSON.stringify(searchRequest), From 34bc348f9024ca17912ee2d10ead47eecc54cc20 Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:54:55 +0100 Subject: [PATCH 07/29] refactor(share): use proxyFetch Replace native fetch() with proxyFetch() in: - share.ts: sync, create, remove (3 calls) - share-next.ts: create, sync, remove (3 calls) These fetch calls communicate with OpenCode sharing API. --- packages/opencode/src/share/share-next.ts | 7 ++++--- packages/opencode/src/share/share.ts | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index dddce95cb4f9..b7248a442fb9 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -6,6 +6,7 @@ import { Session } from "@/session" import { MessageV2 } from "@/session/message-v2" import { Storage } from "@/storage/storage" import { Log } from "@/util/log" +import { proxyFetch } from "@/util/fetch" import type * as SDK from "@opencode-ai/sdk/v2" export namespace ShareNext { @@ -68,7 +69,7 @@ export namespace ShareNext { export async function create(sessionID: string) { if (disabled) return { id: "", url: "", secret: "" } log.info("creating share", { sessionID }) - const result = await fetch(`${await url()}/api/share`, { + const result = await proxyFetch(`${await url()}/api/share`, { method: "POST", headers: { "Content-Type": "application/json", @@ -135,7 +136,7 @@ export namespace ShareNext { const share = await get(sessionID).catch(() => undefined) if (!share) return - await fetch(`${await url()}/api/share/${share.id}/sync`, { + await proxyFetch(`${await url()}/api/share/${share.id}/sync`, { method: "POST", headers: { "Content-Type": "application/json", @@ -154,7 +155,7 @@ export namespace ShareNext { log.info("removing share", { sessionID }) const share = await get(sessionID) if (!share) return - await fetch(`${await url()}/api/share/${share.id}`, { + await proxyFetch(`${await url()}/api/share/${share.id}`, { method: "DELETE", headers: { "Content-Type": "application/json", diff --git a/packages/opencode/src/share/share.ts b/packages/opencode/src/share/share.ts index f7bf4b3fa52a..ec66242645f4 100644 --- a/packages/opencode/src/share/share.ts +++ b/packages/opencode/src/share/share.ts @@ -3,6 +3,7 @@ import { Installation } from "../installation" import { Session } from "../session" import { MessageV2 } from "../session/message-v2" import { Log } from "../util/log" +import { proxyFetch } from "../util/fetch" export namespace Share { const log = Log.create({ service: "share" }) @@ -26,7 +27,7 @@ export namespace Share { if (content === undefined) return pending.delete(key) - return fetch(`${URL}/share_sync`, { + return proxyFetch(`${URL}/share_sync`, { method: "POST", body: JSON.stringify({ sessionID: sessionID, @@ -74,7 +75,7 @@ export namespace Share { export async function create(sessionID: string) { if (disabled) return { url: "", secret: "" } - return fetch(`${URL}/share_create`, { + return proxyFetch(`${URL}/share_create`, { method: "POST", body: JSON.stringify({ sessionID: sessionID }), }) @@ -84,7 +85,7 @@ export namespace Share { export async function remove(sessionID: string, secret: string) { if (disabled) return {} - return fetch(`${URL}/share_delete`, { + return proxyFetch(`${URL}/share_delete`, { method: "POST", body: JSON.stringify({ sessionID, secret }), }).then((x) => x.json()) From 3006a2c2ad6784d6bcaa18b9f24544cb0e6e7982 Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:55:50 +0100 Subject: [PATCH 08/29] refactor(mcp): use proxyFetch for remote transports Pass proxyFetch to MCP SDK transports via the 'fetch' option: - StreamableHTTPClientTransport (2 instances) - SSEClientTransport (1 instance) The MCP SDK uses internal fetch calls for HTTP communication. By passing our proxyFetch wrapper, remote MCP servers become accessible through corporate proxies. This is critical for users connecting to remote MCP servers like mcp.exa.ai from behind a proxy. --- packages/opencode/src/mcp/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 29e958fe3572..1f94451da32a 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -22,6 +22,7 @@ import { McpAuth } from "./auth" import { BusEvent } from "../bus/bus-event" import { Bus } from "@/bus" import { TuiEvent } from "@/cli/cmd/tui/event" +import { proxyFetch } from "@/util/fetch" import open from "open" export namespace MCP { @@ -331,6 +332,7 @@ export namespace MCP { transport: new StreamableHTTPClientTransport(new URL(mcp.url), { authProvider, requestInit: mcp.headers ? { headers: mcp.headers } : undefined, + fetch: proxyFetch, }), }, { @@ -338,6 +340,7 @@ export namespace MCP { transport: new SSEClientTransport(new URL(mcp.url), { authProvider, requestInit: mcp.headers ? { headers: mcp.headers } : undefined, + fetch: proxyFetch, }), }, ] @@ -755,6 +758,7 @@ export namespace MCP { // Create transport with auth provider const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider, + fetch: proxyFetch, }) // Try to connect - this will trigger the OAuth flow From 4dfb1de36c8548088efc13ffe1ed5daa536a2d20 Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:56:30 +0100 Subject: [PATCH 09/29] refactor(lsp): use proxyFetch for JS/TS servers Add proxyFetch import and replace fetch for ESLint server download. This enables ESLint VSCode extension download through corporate proxies. --- packages/opencode/src/lsp/server.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index b0755b8b563c..4be9f5ae0f57 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -10,6 +10,7 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" import { Archive } from "../util/archive" +import { proxyFetch } from "../util/fetch" export namespace LSPServer { const log = Log.create({ service: "lsp.server" }) @@ -176,7 +177,7 @@ export namespace LSPServer { if (!(await Bun.file(serverPath).exists())) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("downloading and building VS Code ESLint server") - const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip") + const response = await proxyFetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip") if (!response.ok) return const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip") From 6b3162d66edd18d559f541275534cf1d2ebad5bc Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:57:02 +0100 Subject: [PATCH 10/29] refactor(lsp): use proxyFetch for system language servers Replace fetch with proxyFetch for: - ZLS (Zig Language Server): release info + binary download - Clangd (C/C++): release info + binary download These servers download binaries from GitHub releases. --- packages/opencode/src/lsp/server.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 4be9f5ae0f57..f9d51c68274e 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -638,7 +638,7 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("downloading zls from GitHub releases") - const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest") + const releaseResponse = await proxyFetch("https://api.github.com/repos/zigtools/zls/releases/latest") if (!releaseResponse.ok) { log.error("Failed to fetch zls release info") return @@ -686,7 +686,7 @@ export namespace LSPServer { } const downloadUrl = asset.browser_download_url - const downloadResponse = await fetch(downloadUrl) + const downloadResponse = await proxyFetch(downloadUrl) if (!downloadResponse.ok) { log.error("Failed to download zls") return @@ -933,7 +933,7 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("downloading clangd from GitHub releases") - const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest") + const releaseResponse = await proxyFetch("https://api.github.com/repos/clangd/clangd/releases/latest") if (!releaseResponse.ok) { log.error("Failed to fetch clangd release info") return @@ -979,7 +979,7 @@ export namespace LSPServer { } const name = asset.name - const downloadResponse = await fetch(asset.browser_download_url) + const downloadResponse = await proxyFetch(asset.browser_download_url) if (!downloadResponse.ok) { log.error("Failed to download clangd") return From c73a8cb6a6e6fdd0d9c0d342ea6a1080723c6c72 Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:58:21 +0100 Subject: [PATCH 11/29] refactor(lsp): use proxyFetch for other servers Replace fetch with proxyFetch for: - Elixir-LS: source download - Kotlin LSP: release info - Lua Language Server: release info + binary download - Terraform-LS: release info + binary download - TexLab: release info + binary download - Tinymist: release info + binary download All these servers download binaries from GitHub. --- packages/opencode/src/lsp/server.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index f9d51c68274e..ba391a04e23a 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -582,7 +582,7 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("downloading elixir-ls from GitHub releases") - const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip") + const response = await proxyFetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip") if (!response.ok) return const zipPath = path.join(Global.Path.bin, "elixir-ls.zip") await Bun.file(zipPath).write(response) @@ -1254,7 +1254,7 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("Downloading Kotlin Language Server from GitHub.") - const releaseResponse = await fetch("https://api.github.com/repos/Kotlin/kotlin-lsp/releases/latest") + const releaseResponse = await proxyFetch("https://api.github.com/repos/Kotlin/kotlin-lsp/releases/latest") if (!releaseResponse.ok) { log.error("Failed to fetch kotlin-lsp release info") return @@ -1389,7 +1389,7 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("downloading lua-language-server from GitHub releases") - const releaseResponse = await fetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest") + const releaseResponse = await proxyFetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest") if (!releaseResponse.ok) { log.error("Failed to fetch lua-language-server release info") return @@ -1437,7 +1437,7 @@ export namespace LSPServer { } const downloadUrl = asset.browser_download_url - const downloadResponse = await fetch(downloadUrl) + const downloadResponse = await proxyFetch(downloadUrl) if (!downloadResponse.ok) { log.error("Failed to download lua-language-server") return @@ -1657,7 +1657,7 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("downloading terraform-ls from GitHub releases") - const releaseResponse = await fetch("https://api.github.com/repos/hashicorp/terraform-ls/releases/latest") + const releaseResponse = await proxyFetch("https://api.github.com/repos/hashicorp/terraform-ls/releases/latest") if (!releaseResponse.ok) { log.error("Failed to fetch terraform-ls release info") return @@ -1688,7 +1688,7 @@ export namespace LSPServer { return } - const downloadResponse = await fetch(asset.browser_download_url) + const downloadResponse = await proxyFetch(asset.browser_download_url) if (!downloadResponse.ok) { log.error("Failed to download terraform-ls") return @@ -1747,7 +1747,7 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("downloading texlab from GitHub releases") - const response = await fetch("https://api.github.com/repos/latex-lsp/texlab/releases/latest") + const response = await proxyFetch("https://api.github.com/repos/latex-lsp/texlab/releases/latest") if (!response.ok) { log.error("Failed to fetch texlab release info") return @@ -1778,7 +1778,7 @@ export namespace LSPServer { return } - const downloadResponse = await fetch(asset.browser_download_url) + const downloadResponse = await proxyFetch(asset.browser_download_url) if (!downloadResponse.ok) { log.error("Failed to download texlab") return @@ -1946,7 +1946,7 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("downloading tinymist from GitHub releases") - const response = await fetch("https://api.github.com/repos/Myriad-Dreamin/tinymist/releases/latest") + const response = await proxyFetch("https://api.github.com/repos/Myriad-Dreamin/tinymist/releases/latest") if (!response.ok) { log.error("Failed to fetch tinymist release info") return @@ -1984,7 +1984,7 @@ export namespace LSPServer { return } - const downloadResponse = await fetch(asset.browser_download_url) + const downloadResponse = await proxyFetch(asset.browser_download_url) if (!downloadResponse.ok) { log.error("Failed to download tinymist") return From ad339e7751c907261e3c9d0a0a1a4a8888e5b4d1 Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:58:56 +0100 Subject: [PATCH 12/29] refactor(ripgrep): use proxyFetch for binary download Replace fetch with proxyFetch for ripgrep binary download. This is critical as ripgrep is used for file searching/grep operations. --- packages/opencode/src/file/ripgrep.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index c1e5113bf89c..b4763af7e3f5 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -9,6 +9,7 @@ import { $ } from "bun" import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js" import { Log } from "@/util/log" +import { proxyFetch } from "@/util/fetch" export namespace Ripgrep { const log = Log.create({ service: "ripgrep" }) @@ -137,7 +138,7 @@ export namespace Ripgrep { const filename = `ripgrep-${version}-${config.platform}.${config.extension}` const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}` - const response = await fetch(url) + const response = await proxyFetch(url) if (!response.ok) throw new DownloadFailedError({ url, status: response.status }) const buffer = await response.arrayBuffer() From b237159b54531d16a746c08c7f67c39b586387e4 Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:59:32 +0100 Subject: [PATCH 13/29] refactor(config): use proxyFetch for well-known Replace fetch with proxyFetch for .well-known/opencode endpoint. This enables enterprise config discovery through proxies. --- packages/opencode/src/config/config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 68edd5d5e92e..b65b121b4d99 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -12,6 +12,7 @@ import { lazy } from "../util/lazy" import { NamedError } from "@opencode-ai/util/error" import { Flag } from "../flag/flag" import { Auth } from "../auth" +import { proxyFetch } from "../util/fetch" import { type ParseError as JsoncParseError, applyEdits, @@ -78,7 +79,7 @@ export namespace Config { if (value.type === "wellknown") { process.env[value.key] = value.token log.debug("fetching remote config", { url: `${key}/.well-known/opencode` }) - const response = await fetch(`${key}/.well-known/opencode`) + const response = await proxyFetch(`${key}/.well-known/opencode`) if (!response.ok) { throw new Error(`failed to fetch remote config from ${key}: ${response.status}`) } From 1e4ee2448a190e4c313f96a38a16a0e41f029978 Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:00:07 +0100 Subject: [PATCH 14/29] refactor(instruction): use proxyFetch for remote instructions Replace fetch with proxyFetch for remote instruction URLs. This enables loading instructions from remote URLs through proxies. --- packages/opencode/src/session/instruction.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 6fb2a7aeb57f..27f8b3772a35 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -6,6 +6,7 @@ import { Config } from "../config/config" import { Instance } from "../project/instance" import { Flag } from "@/flag/flag" import { Log } from "../util/log" +import { proxyFetch } from "../util/fetch" import type { MessageV2 } from "./message-v2" const log = Log.create({ service: "instruction" }) @@ -135,7 +136,7 @@ export namespace InstructionPrompt { } } const fetches = urls.map((url) => - fetch(url, { signal: AbortSignal.timeout(5000) }) + proxyFetch(url, { signal: AbortSignal.timeout(5000) }) .then((res) => (res.ok ? res.text() : "")) .catch(() => "") .then((x) => (x ? "Instructions from: " + url + "\n" + x : "")), From ddb25e7d5ebd8a000881f98868aa23e7c8a73e3f Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:02:00 +0100 Subject: [PATCH 15/29] refactor(plugins): use proxyFetch for OAuth Replace fetch with proxyFetch in: - copilot.ts: API calls, device code flow, access token flow - codex.ts: Token exchange, token refresh, API calls These plugins use OAuth flows that require external API access. --- packages/opencode/src/plugin/codex.ts | 9 +++++---- packages/opencode/src/plugin/copilot.ts | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index c8b833baeca3..20fdf0bcd6c4 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -2,6 +2,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { Log } from "../util/log" import { Installation } from "../installation" import { Auth, OAUTH_DUMMY_KEY } from "../auth" +import { proxyFetch } from "../util/fetch" import os from "os" import { ProviderTransform } from "@/provider/transform" @@ -109,7 +110,7 @@ interface TokenResponse { } async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise { - const response = await fetch(`${ISSUER}/oauth/token`, { + const response = await proxyFetch(`${ISSUER}/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -127,7 +128,7 @@ async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: Pk } async function refreshAccessToken(refreshToken: string): Promise { - const response = await fetch(`${ISSUER}/oauth/token`, { + const response = await proxyFetch(`${ISSUER}/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -429,7 +430,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { } const currentAuth = await getAuth() - if (currentAuth.type !== "oauth") return fetch(requestInput, init) + if (currentAuth.type !== "oauth") return proxyFetch(requestInput, init) // Cast to include accountId field const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string } @@ -487,7 +488,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { ? new URL(CODEX_API_ENDPOINT) : parsed - return fetch(url, { + return proxyFetch(url, { ...init, headers, }) diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts index 39ea0d00d28e..c13cf88e51d4 100644 --- a/packages/opencode/src/plugin/copilot.ts +++ b/packages/opencode/src/plugin/copilot.ts @@ -1,6 +1,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { Installation } from "@/installation" import { iife } from "@/util/iife" +import { proxyFetch } from "@/util/fetch" const CLIENT_ID = "Ov23li8tweQw6odWQebz" // Add a small safety buffer when polling to avoid hitting the server @@ -62,7 +63,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { apiKey: "", async fetch(request: RequestInfo | URL, init?: RequestInit) { const info = await getAuth() - if (info.type !== "oauth") return fetch(request, init) + if (info.type !== "oauth") return proxyFetch(request, init) const url = request instanceof URL ? request.href : request.toString() const { isVision, isAgent } = iife(() => { @@ -133,7 +134,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { delete headers["x-api-key"] delete headers["authorization"] - return fetch(request, { + return proxyFetch(request, { ...init, headers, }) @@ -194,7 +195,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { const urls = getUrls(domain) - const deviceResponse = await fetch(urls.DEVICE_CODE_URL, { + const deviceResponse = await proxyFetch(urls.DEVICE_CODE_URL, { method: "POST", headers: { Accept: "application/json", @@ -224,7 +225,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { method: "auto" as const, async callback() { while (true) { - const response = await fetch(urls.ACCESS_TOKEN_URL, { + const response = await proxyFetch(urls.ACCESS_TOKEN_URL, { method: "POST", headers: { Accept: "application/json", From a8c003bbbd9406188dd94f736fff2e4f6275bf47 Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:05:10 +0100 Subject: [PATCH 16/29] refactor(cli): use proxyFetch for CLI commands Replace fetch() with proxyFetch() in CLI command files: - auth.ts: Provider authentication well-known endpoint - import.ts: Session import from opncd.ai URLs - mcp.ts: MCP server list refresh from models.opencode.ai - github.ts: GitHub API calls for Copilot auth and token exchange --- packages/opencode/src/cli/cmd/auth.ts | 3 ++- packages/opencode/src/cli/cmd/github.ts | 11 ++++++----- packages/opencode/src/cli/cmd/import.ts | 3 ++- packages/opencode/src/cli/cmd/mcp.ts | 3 ++- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 34e2269d0c16..f3afd71549d8 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -10,6 +10,7 @@ import { Config } from "../../config/config" import { Global } from "../../global" import { Plugin } from "../../plugin" import { Instance } from "../../project/instance" +import { proxyFetch } from "../../util/fetch" import type { Hooks } from "@opencode-ai/plugin" type PluginAuth = NonNullable @@ -229,7 +230,7 @@ export const AuthLoginCommand = cmd({ UI.empty() prompts.intro("Add credential") if (args.url) { - const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any) + const wellknown = await proxyFetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any) prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) const proc = Bun.spawn({ cmd: wellknown.auth.command, diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 927c964c9d8b..8e9979880682 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -18,6 +18,7 @@ import type { import { UI } from "../ui" import { cmd } from "./cmd" import { ModelsDev } from "../../provider/models" +import { proxyFetch } from "../../util/fetch" import { Instance } from "@/project/instance" import { bootstrap } from "../bootstrap" import { Session } from "../../session" @@ -354,7 +355,7 @@ export const GithubInstallCommand = cmd({ s.stop("Installed GitHub app") async function getInstallation() { - return await fetch( + return await proxyFetch( `https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`, ) .then((res) => res.json()) @@ -784,7 +785,7 @@ export const GithubRunCommand = cmd({ const filename = path.basename(url) // Download image - const res = await fetch(url, { + const res = await proxyFetch(url, { headers: { Authorization: `Bearer ${appToken}`, Accept: "application/vnd.github.v3+json", @@ -973,14 +974,14 @@ export const GithubRunCommand = cmd({ async function exchangeForAppToken(token: string) { const response = token.startsWith("github_pat_") - ? await fetch(`${oidcBaseUrl}/exchange_github_app_token_with_pat`, { + ? await proxyFetch(`${oidcBaseUrl}/exchange_github_app_token_with_pat`, { method: "POST", headers: { Authorization: `Bearer ${token}`, }, body: JSON.stringify({ owner, repo }), }) - : await fetch(`${oidcBaseUrl}/exchange_github_app_token`, { + : await proxyFetch(`${oidcBaseUrl}/exchange_github_app_token`, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -1534,7 +1535,7 @@ query($owner: String!, $repo: String!, $number: Int!) { async function revokeAppToken() { if (!appToken) return - await fetch("https://api.github.com/installation/token", { + await proxyFetch("https://api.github.com/installation/token", { method: "DELETE", headers: { Authorization: `Bearer ${appToken}`, diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 9d7e8c56171e..1774991ab5b2 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -4,6 +4,7 @@ import { cmd } from "./cmd" import { bootstrap } from "../bootstrap" import { Storage } from "../../storage/storage" import { Instance } from "../../project/instance" +import { proxyFetch } from "../../util/fetch" import { EOL } from "os" export const ImportCommand = cmd({ @@ -39,7 +40,7 @@ export const ImportCommand = cmd({ } const slug = urlMatch[1] - const response = await fetch(`https://opncd.ai/api/share/${slug}`) + const response = await proxyFetch(`https://opncd.ai/api/share/${slug}`) if (!response.ok) { process.stdout.write(`Failed to fetch share data: ${response.statusText}`) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 95719215e324..5724c644311c 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -10,6 +10,7 @@ import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "../../config/config" import { Instance } from "../../project/instance" import { Installation } from "../../installation" +import { proxyFetch } from "../../util/fetch" import path from "path" import { Global } from "../../global" import { modify, applyEdits } from "jsonc-parser" @@ -651,7 +652,7 @@ export const McpDebugCommand = cmd({ // Test basic HTTP connectivity first try { - const response = await fetch(serverConfig.url, { + const response = await proxyFetch(serverConfig.url, { method: "POST", headers: { "Content-Type": "application/json", From 76180196934a0821d2944c851a581f7832fe8b1f Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:06:25 +0100 Subject: [PATCH 17/29] test(fetch): add integration tests for proxy support Add integration tests that: - Test actual proxy requests (skipped in CI) - Verify NO_PROXY bypass patterns - Test OPENCODE_DISABLE_PROXY flag - Test config override of environment variables Tests require HTTPS_PROXY to be set to run real proxy tests. --- .../test/util/fetch-integration.test.ts | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 packages/opencode/test/util/fetch-integration.test.ts diff --git a/packages/opencode/test/util/fetch-integration.test.ts b/packages/opencode/test/util/fetch-integration.test.ts new file mode 100644 index 000000000000..3dca37f7cd66 --- /dev/null +++ b/packages/opencode/test/util/fetch-integration.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import { proxyFetch, setProxyConfig, getProxyForUrl } from "../../src/util/fetch" + +/** + * Integration tests for proxy fetch functionality. + * These tests require a real proxy server and are skipped in CI. + * + * To run locally with a proxy: + * 1. Set HTTP_PROXY and/or HTTPS_PROXY environment variables + * 2. Run: bun test test/util/fetch-integration.test.ts + * + * Example: + * HTTPS_PROXY=http://proxyaws.pole-emploi.intra:8080 bun test test/util/fetch-integration.test.ts + */ + +// Skip integration tests in CI or when no proxy is configured +const hasProxy = !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy) +const isCI = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI) +const shouldSkip = isCI || !hasProxy + +describe("proxy-fetch integration", () => { + // Save original env vars + const originalEnv = { ...process.env } + + afterEach(() => { + // Restore original env vars + Object.assign(process.env, originalEnv) + // Clear config + setProxyConfig(undefined) + }) + + describe("real proxy requests", () => { + test.skipIf(shouldSkip)("fetches through proxy", async () => { + // This test requires a real proxy configured via environment variables + const proxyUrl = getProxyForUrl("https://httpbin.org/get") + expect(proxyUrl).toBeDefined() + + const response = await proxyFetch("https://httpbin.org/get", { + headers: { + "User-Agent": "opencode-test/1.0", + }, + }) + + expect(response.ok).toBe(true) + const data = await response.json() as { headers: Record } + expect(data.headers).toBeDefined() + }) + + test.skipIf(shouldSkip)("POST request through proxy", async () => { + const response = await proxyFetch("https://httpbin.org/post", { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "opencode-test/1.0", + }, + body: JSON.stringify({ test: "data" }), + }) + + expect(response.ok).toBe(true) + const data = await response.json() as { json: { test: string } } + expect(data.json).toEqual({ test: "data" }) + }) + + test.skipIf(shouldSkip)("handles proxy timeout gracefully", async () => { + // Test with a very short timeout to simulate network issues + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 100) + + try { + await proxyFetch("https://httpbin.org/delay/5", { + signal: controller.signal, + }) + // Should not reach here + expect(true).toBe(false) + } catch (error) { + expect(error).toBeDefined() + } finally { + clearTimeout(timeoutId) + } + }) + }) + + describe("NO_PROXY bypass", () => { + beforeEach(() => { + // Ensure proxy is set for these tests + if (!process.env.HTTPS_PROXY && !process.env.https_proxy) { + process.env.HTTPS_PROXY = "http://localhost:9999" // Non-existent proxy + } + }) + + test("bypasses proxy for NO_PROXY hosts", async () => { + process.env.NO_PROXY = "httpbin.org" + + // Should NOT use proxy due to NO_PROXY + const proxyUrl = getProxyForUrl("https://httpbin.org/get") + expect(proxyUrl).toBeUndefined() + + // Should be able to fetch directly (no proxy) + const response = await proxyFetch("https://httpbin.org/get", { + headers: { + "User-Agent": "opencode-test/1.0", + }, + }) + + expect(response.ok).toBe(true) + }) + + test("bypasses proxy for localhost", async () => { + process.env.NO_PROXY = "localhost,127.0.0.1" + + expect(getProxyForUrl("http://localhost:3000")).toBeUndefined() + expect(getProxyForUrl("http://127.0.0.1:8080")).toBeUndefined() + }) + + test("bypasses proxy for wildcard domain", async () => { + process.env.NO_PROXY = "*.internal.com,*.ft.intra" + + expect(getProxyForUrl("https://api.internal.com/endpoint")).toBeUndefined() + expect(getProxyForUrl("https://alfred.ft.intra/api")).toBeUndefined() + }) + + test("uses proxy for non-matching hosts", async () => { + process.env.NO_PROXY = "localhost,*.internal.com" + + const proxyUrl = getProxyForUrl("https://external.example.com") + expect(proxyUrl).toBeDefined() + }) + }) + + describe("OPENCODE_DISABLE_PROXY", () => { + test("disables all proxy when flag is set", () => { + process.env.HTTPS_PROXY = "http://proxy:8080" + process.env.OPENCODE_DISABLE_PROXY = "1" + + expect(getProxyForUrl("https://example.com")).toBeUndefined() + }) + + test("respects falsy disable values", () => { + process.env.HTTPS_PROXY = "http://proxy:8080" + process.env.OPENCODE_DISABLE_PROXY = "0" + + // "0" is truthy as a string, so proxy should be disabled + expect(getProxyForUrl("https://example.com")).toBeUndefined() + }) + + test("enables proxy when flag is unset", () => { + process.env.HTTPS_PROXY = "http://proxy:8080" + delete process.env.OPENCODE_DISABLE_PROXY + + expect(getProxyForUrl("https://example.com")).toBe("http://proxy:8080") + }) + }) + + describe("config override", () => { + test("config proxy overrides environment", () => { + process.env.HTTPS_PROXY = "http://env-proxy:8080" + + setProxyConfig({ + https: "http://config-proxy:8080", + }) + + expect(getProxyForUrl("https://example.com")).toBe("http://config-proxy:8080") + }) + + test("config no_proxy overrides environment", () => { + process.env.HTTPS_PROXY = "http://proxy:8080" + process.env.NO_PROXY = "env.local" + + setProxyConfig({ + no_proxy: ["config.local"], + }) + + // env.local should now use proxy (not in config no_proxy) + expect(getProxyForUrl("https://env.local")).toBe("http://proxy:8080") + // config.local should bypass proxy + expect(getProxyForUrl("https://config.local")).toBeUndefined() + }) + }) +}) From 02cb0e7fa0a291a5f6d6f093083480f7a6afe387 Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:07:32 +0100 Subject: [PATCH 18/29] docs: add proxy configuration documentation Add comprehensive documentation for HTTP/HTTPS proxy support: - Environment variables (HTTP_PROXY, HTTPS_PROXY, NO_PROXY) - Configuration file options (opencode.json proxy section) - NO_PROXY pattern syntax and examples - Troubleshooting guide - Example configurations for common scenarios --- packages/opencode/docs/proxy.md | 176 ++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 packages/opencode/docs/proxy.md diff --git a/packages/opencode/docs/proxy.md b/packages/opencode/docs/proxy.md new file mode 100644 index 000000000000..be4a5eb8a1b1 --- /dev/null +++ b/packages/opencode/docs/proxy.md @@ -0,0 +1,176 @@ +# Proxy Configuration + +OpenCode supports HTTP/HTTPS proxy configuration for environments that require outbound traffic to go through a corporate proxy. + +## Quick Start + +Set the environment variables before running OpenCode: + +```bash +export HTTPS_PROXY=http://proxy.example.com:8080 +export HTTP_PROXY=http://proxy.example.com:8080 +export NO_PROXY=localhost,127.0.0.1,*.internal.com + +opencode +``` + +## Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `HTTPS_PROXY` | Proxy URL for HTTPS requests | `http://proxy:8080` | +| `HTTP_PROXY` | Proxy URL for HTTP requests | `http://proxy:8080` | +| `NO_PROXY` | Comma-separated list of hosts to bypass | `localhost,*.internal.com` | +| `https_proxy` | Alternative (lowercase) | Same as above | +| `http_proxy` | Alternative (lowercase) | Same as above | +| `no_proxy` | Alternative (lowercase) | Same as above | +| `OPENCODE_DISABLE_PROXY` | Emergency bypass flag | `1` to disable | + +## Configuration File + +You can also configure proxy settings in `opencode.json`: + +```json +{ + "proxy": { + "http": "http://proxy.example.com:8080", + "https": "http://proxy.example.com:8080", + "no_proxy": ["localhost", "127.0.0.1", "*.internal.com"] + } +} +``` + +**Priority Order:** +1. Configuration file (`opencode.json`) - highest priority +2. Environment variables - fallback + +## NO_PROXY Patterns + +The `NO_PROXY` / `no_proxy` setting supports these patterns: + +| Pattern | Description | Example | +|---------|-------------|---------| +| `*` | Bypass all hosts (disable proxy) | `*` | +| `hostname` | Exact match or suffix match | `localhost` | +| `*.domain.com` | Wildcard subdomain match | `*.internal.com` | +| `.domain.com` | Suffix match (subdomains only) | `.ft.intra` | +| `192.168.1.100` | Exact IP match | - | + +### Examples + +```bash +# Bypass proxy for localhost and internal networks +NO_PROXY=localhost,127.0.0.1,*.internal.com,.ft.intra + +# Bypass proxy for all hosts (emergency) +NO_PROXY=* +``` + +## Proxy URL Format + +Standard proxy URL format: + +``` +http://[user:password@]host:port +``` + +**Examples:** +```bash +# Without authentication +HTTPS_PROXY=http://proxy.example.com:8080 + +# With authentication +HTTPS_PROXY=http://user:password@proxy.example.com:8080 +``` + +## Emergency Bypass + +If the proxy configuration causes issues, you can temporarily disable it: + +```bash +OPENCODE_DISABLE_PROXY=1 opencode +``` + +This bypasses all proxy settings and uses direct connections. + +## What Uses the Proxy + +All HTTP/HTTPS requests made by OpenCode go through the proxy when configured: + +- Model API calls (Anthropic, OpenAI, etc.) +- Provider authentication (OAuth flows) +- Version check and auto-update +- LSP language server downloads +- MCP server connections (HTTP/SSE transports) +- Web fetch/search tools +- GitHub API integration +- Share functionality + +## Limitations + +- **WebSocket connections**: Not proxied (Bun limitation) +- **Local MCP servers**: stdio-based servers are not affected + +## Troubleshooting + +### Proxy not being used + +1. Check if the environment variable is set: + ```bash + echo $HTTPS_PROXY + ``` + +2. Verify the URL format is correct (must include `http://`) + +3. Check if the host is in NO_PROXY: + ```bash + echo $NO_PROXY + ``` + +### SSL/TLS errors through proxy + +Some corporate proxies perform SSL interception. You may need to: + +1. Configure the proxy's CA certificate in your system trust store +2. Set `NODE_TLS_REJECT_UNAUTHORIZED=0` (not recommended for production) + +### Proxy authentication failing + +Ensure special characters in password are URL-encoded: + +```bash +# Password with @ symbol +HTTPS_PROXY=http://user:p%40ssword@proxy.example.com:8080 +``` + +## Example Configurations + +### Corporate Proxy (France Travail) + +```bash +export HTTPS_PROXY=http://proxyaws.pole-emploi.intra:8080 +export HTTP_PROXY=http://proxyaws.pole-emploi.intra:8080 +export NO_PROXY=localhost,127.0.0.1,*.pole-emploi.intra,*.ft.intra +``` + +### Docker with Proxy + +```dockerfile +ENV HTTPS_PROXY=http://proxy.example.com:8080 +ENV HTTP_PROXY=http://proxy.example.com:8080 +ENV NO_PROXY=localhost,127.0.0.1 +``` + +### CI/CD Pipeline + +```yaml +# GitLab CI +variables: + HTTPS_PROXY: http://proxy.example.com:8080 + NO_PROXY: localhost,127.0.0.1,*.internal.com + +# GitHub Actions +env: + HTTPS_PROXY: http://proxy.example.com:8080 + NO_PROXY: localhost,127.0.0.1 +``` From c13f25c47d22d8b90518f0d2b265c67cb278c4af Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:48:57 +0100 Subject: [PATCH 19/29] fix(opencode): resolve type intersection for proxy option --- packages/opencode/src/util/fetch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/util/fetch.ts b/packages/opencode/src/util/fetch.ts index fce3f020a66f..4a8d61353cd8 100644 --- a/packages/opencode/src/util/fetch.ts +++ b/packages/opencode/src/util/fetch.ts @@ -125,7 +125,7 @@ export function getProxyForUrl(url: string | URL): string | undefined { */ export async function proxyFetch( input: RequestInfo | URL, - init?: BunFetchInit & { proxy?: string | false | { url: string; headers?: Record } }, + init?: Omit & { proxy?: string | false | { url: string; headers?: Record } }, ): Promise { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url From c824688d3c8efb0eb4c302d864287ab2469f118d Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:50:16 +0100 Subject: [PATCH 20/29] fix(opencode): wire proxy config from opencode.json to fetch layer --- packages/opencode/src/config/config.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index b65b121b4d99..33405982a096 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -12,7 +12,7 @@ import { lazy } from "../util/lazy" import { NamedError } from "@opencode-ai/util/error" import { Flag } from "../flag/flag" import { Auth } from "../auth" -import { proxyFetch } from "../util/fetch" +import { proxyFetch, setProxyConfig } from "../util/fetch" import { type ParseError as JsoncParseError, applyEdits, @@ -236,6 +236,9 @@ export namespace Config { result.plugin = deduplicatePlugins(result.plugin ?? []) + // Wire proxy config to fetch layer + setProxyConfig(result.proxy) + return { config: result, directories, From 14e20ba8f182f7c4903042352636a171df4499e3 Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:51:15 +0100 Subject: [PATCH 21/29] test(opencode): improve proxy test cleanup and naming --- .../test/util/fetch-integration.test.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/opencode/test/util/fetch-integration.test.ts b/packages/opencode/test/util/fetch-integration.test.ts index 3dca37f7cd66..a89a6dc57c17 100644 --- a/packages/opencode/test/util/fetch-integration.test.ts +++ b/packages/opencode/test/util/fetch-integration.test.ts @@ -14,7 +14,12 @@ import { proxyFetch, setProxyConfig, getProxyForUrl } from "../../src/util/fetch */ // Skip integration tests in CI or when no proxy is configured -const hasProxy = !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy) +const hasProxy = !!( + process.env.HTTP_PROXY || + process.env.HTTPS_PROXY || + process.env.http_proxy || + process.env.https_proxy +) const isCI = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI) const shouldSkip = isCI || !hasProxy @@ -23,6 +28,12 @@ describe("proxy-fetch integration", () => { const originalEnv = { ...process.env } afterEach(() => { + // Remove env vars introduced during tests + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) { + delete process.env[key] + } + } // Restore original env vars Object.assign(process.env, originalEnv) // Clear config @@ -42,7 +53,7 @@ describe("proxy-fetch integration", () => { }) expect(response.ok).toBe(true) - const data = await response.json() as { headers: Record } + const data = (await response.json()) as { headers: Record } expect(data.headers).toBeDefined() }) @@ -57,7 +68,7 @@ describe("proxy-fetch integration", () => { }) expect(response.ok).toBe(true) - const data = await response.json() as { json: { test: string } } + const data = (await response.json()) as { json: { test: string } } expect(data.json).toEqual({ test: "data" }) }) @@ -135,11 +146,11 @@ describe("proxy-fetch integration", () => { expect(getProxyForUrl("https://example.com")).toBeUndefined() }) - test("respects falsy disable values", () => { + test("disables proxy when flag is any non-empty string", () => { process.env.HTTPS_PROXY = "http://proxy:8080" process.env.OPENCODE_DISABLE_PROXY = "0" - // "0" is truthy as a string, so proxy should be disabled + // Any non-empty string (including "0") disables the proxy expect(getProxyForUrl("https://example.com")).toBeUndefined() }) From cba1dbd8131f4769b37925afc5d066e51b0c5aa6 Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:52:01 +0100 Subject: [PATCH 22/29] docs(opencode): replace insecure TLS workaround with proper solutions --- packages/opencode/docs/proxy.md | 38 ++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/packages/opencode/docs/proxy.md b/packages/opencode/docs/proxy.md index be4a5eb8a1b1..11bb1991725c 100644 --- a/packages/opencode/docs/proxy.md +++ b/packages/opencode/docs/proxy.md @@ -16,15 +16,15 @@ opencode ## Environment Variables -| Variable | Description | Example | -|----------|-------------|---------| -| `HTTPS_PROXY` | Proxy URL for HTTPS requests | `http://proxy:8080` | -| `HTTP_PROXY` | Proxy URL for HTTP requests | `http://proxy:8080` | -| `NO_PROXY` | Comma-separated list of hosts to bypass | `localhost,*.internal.com` | -| `https_proxy` | Alternative (lowercase) | Same as above | -| `http_proxy` | Alternative (lowercase) | Same as above | -| `no_proxy` | Alternative (lowercase) | Same as above | -| `OPENCODE_DISABLE_PROXY` | Emergency bypass flag | `1` to disable | +| Variable | Description | Example | +| ------------------------ | --------------------------------------- | -------------------------- | +| `HTTPS_PROXY` | Proxy URL for HTTPS requests | `http://proxy:8080` | +| `HTTP_PROXY` | Proxy URL for HTTP requests | `http://proxy:8080` | +| `NO_PROXY` | Comma-separated list of hosts to bypass | `localhost,*.internal.com` | +| `https_proxy` | Alternative (lowercase) | Same as above | +| `http_proxy` | Alternative (lowercase) | Same as above | +| `no_proxy` | Alternative (lowercase) | Same as above | +| `OPENCODE_DISABLE_PROXY` | Emergency bypass flag | `1` to disable | ## Configuration File @@ -41,6 +41,7 @@ You can also configure proxy settings in `opencode.json`: ``` **Priority Order:** + 1. Configuration file (`opencode.json`) - highest priority 2. Environment variables - fallback @@ -48,13 +49,13 @@ You can also configure proxy settings in `opencode.json`: The `NO_PROXY` / `no_proxy` setting supports these patterns: -| Pattern | Description | Example | -|---------|-------------|---------| -| `*` | Bypass all hosts (disable proxy) | `*` | -| `hostname` | Exact match or suffix match | `localhost` | -| `*.domain.com` | Wildcard subdomain match | `*.internal.com` | -| `.domain.com` | Suffix match (subdomains only) | `.ft.intra` | -| `192.168.1.100` | Exact IP match | - | +| Pattern | Description | Example | +| --------------- | -------------------------------- | ---------------- | +| `*` | Bypass all hosts (disable proxy) | `*` | +| `hostname` | Exact match or suffix match | `localhost` | +| `*.domain.com` | Wildcard subdomain match | `*.internal.com` | +| `.domain.com` | Suffix match (subdomains only) | `.ft.intra` | +| `192.168.1.100` | Exact IP match | - | ### Examples @@ -75,6 +76,7 @@ http://[user:password@]host:port ``` **Examples:** + ```bash # Without authentication HTTPS_PROXY=http://proxy.example.com:8080 @@ -116,6 +118,7 @@ All HTTP/HTTPS requests made by OpenCode go through the proxy when configured: ### Proxy not being used 1. Check if the environment variable is set: + ```bash echo $HTTPS_PROXY ``` @@ -132,7 +135,8 @@ All HTTP/HTTPS requests made by OpenCode go through the proxy when configured: Some corporate proxies perform SSL interception. You may need to: 1. Configure the proxy's CA certificate in your system trust store -2. Set `NODE_TLS_REJECT_UNAUTHORIZED=0` (not recommended for production) +2. Use `NODE_EXTRA_CA_CERTS=/path/to/ca.pem` to trust additional certificates +3. Use `BUN_OPTIONS="--use-system-ca"` to use system certificate store ### Proxy authentication failing From fafcafd86af1bc0cd8f6b8ec6a4283b7e515816c Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:54:59 +0100 Subject: [PATCH 23/29] feat(opencode): add TLS configuration for proxy connections - Add tls.rejectUnauthorized and tls.ca options to ProxyConfig schema - Implement getTlsForProxy() with path traversal validation - Wire TLS config into proxyFetch() when proxy is used - Add security warning when certificate validation is disabled Closes #10227 --- packages/opencode/src/config/config.ts | 13 ++++++++ packages/opencode/src/util/fetch.ts | 44 +++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 33405982a096..1fbf2a7c66df 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1011,6 +1011,19 @@ export namespace Config { .array(z.string()) .optional() .describe("Hosts or patterns to bypass proxy (e.g., localhost, *.internal.com)"), + tls: z + .object({ + rejectUnauthorized: z + .boolean() + .optional() + .describe("Set to false to accept self-signed proxy certificates. Default is true."), + ca: z + .union([z.string(), z.array(z.string())]) + .optional() + .describe("Path(s) to CA certificate file(s) to trust for proxy connections."), + }) + .optional() + .describe("TLS configuration for proxy connections."), }) .strict() .meta({ diff --git a/packages/opencode/src/util/fetch.ts b/packages/opencode/src/util/fetch.ts index 4a8d61353cd8..529c9374090f 100644 --- a/packages/opencode/src/util/fetch.ts +++ b/packages/opencode/src/util/fetch.ts @@ -9,9 +9,15 @@ import type { Config } from "../config/config" -// Bun's fetch supports proxy option but TypeScript types don't include it +// Bun's fetch supports proxy and tls options but TypeScript types don't include them +type BunTlsConfig = { + rejectUnauthorized?: boolean + ca?: BlobPart | BlobPart[] +} + type BunFetchInit = RequestInit & { proxy?: string | { url: string; headers?: Record } + tls?: BunTlsConfig } /** @@ -104,6 +110,40 @@ export function getProxyForUrl(url: string | URL): string | undefined { return urlObj.protocol === "https:" ? config.https : config.http } +/** + * Get TLS configuration for proxy connections + * Returns undefined if no TLS config is set + */ +export function getTlsForProxy(): BunTlsConfig | undefined { + const tls = _configProxy?.tls + if (!tls) return undefined + + const result: BunTlsConfig = {} + + if (tls.rejectUnauthorized !== undefined) { + result.rejectUnauthorized = tls.rejectUnauthorized + // Security warning for insecure config + if (tls.rejectUnauthorized === false) { + console.warn( + "[opencode] WARNING: TLS certificate validation disabled for proxy - connection vulnerable to MITM attacks", + ) + } + } + + if (tls.ca) { + const files = Array.isArray(tls.ca) ? tls.ca : [tls.ca] + // Validate paths don't contain traversal + for (const p of files) { + if (p.includes("..")) { + throw new Error(`Invalid CA path (path traversal not allowed): ${p}`) + } + } + result.ca = files.map((p) => Bun.file(p)) + } + + return Object.keys(result).length ? result : undefined +} + /** * Proxy-aware fetch wrapper * @@ -144,9 +184,11 @@ export async function proxyFetch( const proxyUrl = getProxyForUrl(url) if (proxyUrl) { + const tls = getTlsForProxy() return fetch(input, { ...init, proxy: proxyUrl, + ...(tls && { tls }), } as RequestInit) } From 2d15b75719c2c2b72e970dd37f507980396a12f0 Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:55:42 +0100 Subject: [PATCH 24/29] test(opencode): add tests for proxy TLS configuration - Test getTlsForProxy() returns undefined when no TLS config - Test rejectUnauthorized option (true/false) - Test CA certificate path handling (single/multiple) - Test path traversal validation throws error --- packages/opencode/test/util/fetch.test.ts | 70 +++++++++++++++++++++-- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/packages/opencode/test/util/fetch.test.ts b/packages/opencode/test/util/fetch.test.ts index 7a711e16a86c..e439503284f0 100644 --- a/packages/opencode/test/util/fetch.test.ts +++ b/packages/opencode/test/util/fetch.test.ts @@ -1,10 +1,5 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test" -import { - getProxyConfig, - shouldBypassProxy, - getProxyForUrl, - setProxyConfig, -} from "../../src/util/fetch" +import { getProxyConfig, shouldBypassProxy, getProxyForUrl, setProxyConfig, getTlsForProxy } from "../../src/util/fetch" describe("proxy-fetch", () => { // Save original env vars @@ -197,4 +192,67 @@ describe("proxy-fetch", () => { expect(getProxyForUrl("https://example.com")).toBeUndefined() }) }) + + describe("getTlsForProxy", () => { + test("returns undefined when no TLS config", () => { + setProxyConfig({ https: "http://proxy:8080" }) + expect(getTlsForProxy()).toBeUndefined() + }) + + test("returns undefined when TLS object is empty", () => { + setProxyConfig({ + https: "http://proxy:8080", + tls: {}, + }) + expect(getTlsForProxy()).toBeUndefined() + }) + + test("returns rejectUnauthorized when set to false", () => { + setProxyConfig({ + https: "http://proxy:8080", + tls: { rejectUnauthorized: false }, + }) + const tls = getTlsForProxy() + expect(tls).toBeDefined() + expect(tls?.rejectUnauthorized).toBe(false) + }) + + test("returns rejectUnauthorized when set to true", () => { + setProxyConfig({ + https: "http://proxy:8080", + tls: { rejectUnauthorized: true }, + }) + const tls = getTlsForProxy() + expect(tls).toBeDefined() + expect(tls?.rejectUnauthorized).toBe(true) + }) + + test("returns ca as array when single path", () => { + setProxyConfig({ + https: "http://proxy:8080", + tls: { ca: "/path/to/ca.pem" }, + }) + const tls = getTlsForProxy() + expect(tls).toBeDefined() + expect(Array.isArray(tls?.ca)).toBe(true) + }) + + test("returns ca as array when multiple paths", () => { + setProxyConfig({ + https: "http://proxy:8080", + tls: { ca: ["/path/to/ca1.pem", "/path/to/ca2.pem"] }, + }) + const tls = getTlsForProxy() + expect(tls).toBeDefined() + expect(Array.isArray(tls?.ca)).toBe(true) + }) + + test("throws on path traversal attempt", () => { + setProxyConfig({ + https: "http://proxy:8080", + tls: { ca: "../../../etc/passwd" }, + }) + expect(() => getTlsForProxy()).toThrow("path traversal") + }) + }) }) From 9629601a400a16ddd8504b6390aa0c82c14e0ce7 Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:56:21 +0100 Subject: [PATCH 25/29] docs(opencode): document proxy TLS configuration - Add TLS section explaining CA certificate configuration - Document single and multiple CA certificate paths - Document rejectUnauthorized option with security warning - Add reference from SSL/TLS troubleshooting section --- packages/opencode/docs/proxy.md | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/opencode/docs/proxy.md b/packages/opencode/docs/proxy.md index 11bb1991725c..9206d561681a 100644 --- a/packages/opencode/docs/proxy.md +++ b/packages/opencode/docs/proxy.md @@ -137,6 +137,52 @@ Some corporate proxies perform SSL interception. You may need to: 1. Configure the proxy's CA certificate in your system trust store 2. Use `NODE_EXTRA_CA_CERTS=/path/to/ca.pem` to trust additional certificates 3. Use `BUN_OPTIONS="--use-system-ca"` to use system certificate store +4. Configure `proxy.tls` in opencode.json (see below) + +### Proxy TLS Configuration + +If your corporate proxy uses a self-signed certificate or custom CA, you can configure TLS settings directly in `opencode.json`. + +**Custom CA certificate for proxy (recommended):** + +```json +{ + "proxy": { + "https": "http://proxy:8080", + "tls": { + "ca": "/path/to/proxy-ca.pem" + } + } +} +``` + +**Multiple CA certificates:** + +```json +{ + "proxy": { + "https": "http://proxy:8080", + "tls": { + "ca": ["/path/to/ca1.pem", "/path/to/ca2.pem"] + } + } +} +``` + +**Accept self-signed proxy certificates (use with caution):** + +```json +{ + "proxy": { + "https": "http://proxy:8080", + "tls": { + "rejectUnauthorized": false + } + } +} +``` + +> **Warning:** Setting `rejectUnauthorized: false` disables TLS certificate validation for proxy connections. This makes the connection vulnerable to man-in-the-middle attacks. Only use this for trusted internal proxies where you cannot install the CA certificate. ### Proxy authentication failing From 8a344fd89ccaf81ea3572a121079bca04e3b3b8d Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 22:49:52 +0100 Subject: [PATCH 26/29] fix(opencode): address Copilot review feedback - Add env cleanup loop in fetch.test.ts afterEach (Copilot #2) - Dedupe TLS warning to log once per process using Log utility (Copilot #6) - Clarify OPENCODE_DISABLE_PROXY accepts any non-empty value (Copilot #4) --- packages/opencode/docs/proxy.md | 18 +++++++++--------- packages/opencode/src/util/fetch.ts | 11 +++++++---- packages/opencode/test/util/fetch.test.ts | 6 ++++++ 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/opencode/docs/proxy.md b/packages/opencode/docs/proxy.md index 9206d561681a..5b473c10ca3e 100644 --- a/packages/opencode/docs/proxy.md +++ b/packages/opencode/docs/proxy.md @@ -16,15 +16,15 @@ opencode ## Environment Variables -| Variable | Description | Example | -| ------------------------ | --------------------------------------- | -------------------------- | -| `HTTPS_PROXY` | Proxy URL for HTTPS requests | `http://proxy:8080` | -| `HTTP_PROXY` | Proxy URL for HTTP requests | `http://proxy:8080` | -| `NO_PROXY` | Comma-separated list of hosts to bypass | `localhost,*.internal.com` | -| `https_proxy` | Alternative (lowercase) | Same as above | -| `http_proxy` | Alternative (lowercase) | Same as above | -| `no_proxy` | Alternative (lowercase) | Same as above | -| `OPENCODE_DISABLE_PROXY` | Emergency bypass flag | `1` to disable | +| Variable | Description | Example | +| ------------------------ | ------------------------------------------- | -------------------------- | +| `HTTPS_PROXY` | Proxy URL for HTTPS requests | `http://proxy:8080` | +| `HTTP_PROXY` | Proxy URL for HTTP requests | `http://proxy:8080` | +| `NO_PROXY` | Comma-separated list of hosts to bypass | `localhost,*.internal.com` | +| `https_proxy` | Alternative (lowercase) | Same as above | +| `http_proxy` | Alternative (lowercase) | Same as above | +| `no_proxy` | Alternative (lowercase) | Same as above | +| `OPENCODE_DISABLE_PROXY` | Emergency bypass flag (any non-empty value) | `1` | ## Configuration File diff --git a/packages/opencode/src/util/fetch.ts b/packages/opencode/src/util/fetch.ts index 529c9374090f..4b9eebdf0fc9 100644 --- a/packages/opencode/src/util/fetch.ts +++ b/packages/opencode/src/util/fetch.ts @@ -8,6 +8,9 @@ */ import type { Config } from "../config/config" +import { Log } from "./log" + +const log = Log.create({ service: "fetch" }) // Bun's fetch supports proxy and tls options but TypeScript types don't include them type BunTlsConfig = { @@ -25,6 +28,7 @@ type BunFetchInit = RequestInit & { * Set via setProxyConfig() from opencode.json config */ let _configProxy: Config.ProxyConfig | undefined +let _tlsWarningEmitted = false /** * Set proxy configuration from opencode.json @@ -123,10 +127,9 @@ export function getTlsForProxy(): BunTlsConfig | undefined { if (tls.rejectUnauthorized !== undefined) { result.rejectUnauthorized = tls.rejectUnauthorized // Security warning for insecure config - if (tls.rejectUnauthorized === false) { - console.warn( - "[opencode] WARNING: TLS certificate validation disabled for proxy - connection vulnerable to MITM attacks", - ) + if (tls.rejectUnauthorized === false && !_tlsWarningEmitted) { + log.warn("TLS certificate validation disabled for proxy - connection vulnerable to MITM attacks") + _tlsWarningEmitted = true } } diff --git a/packages/opencode/test/util/fetch.test.ts b/packages/opencode/test/util/fetch.test.ts index e439503284f0..e6ff6fc66cde 100644 --- a/packages/opencode/test/util/fetch.test.ts +++ b/packages/opencode/test/util/fetch.test.ts @@ -20,6 +20,12 @@ describe("proxy-fetch", () => { }) afterEach(() => { + // Remove env vars introduced during tests + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) { + delete process.env[key] + } + } // Restore original env vars Object.assign(process.env, originalEnv) }) From 88e2004a7161484b13c8527ea9a0815a40301eda Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:12:21 +0100 Subject: [PATCH 27/29] test(opencode): add regression tests for issues #6953 and #10710 --- packages/opencode/test/util/fetch.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/opencode/test/util/fetch.test.ts b/packages/opencode/test/util/fetch.test.ts index e6ff6fc66cde..8ae908de09eb 100644 --- a/packages/opencode/test/util/fetch.test.ts +++ b/packages/opencode/test/util/fetch.test.ts @@ -74,6 +74,12 @@ describe("proxy-fetch", () => { expect(config.noProxy).toEqual(["localhost", "127.0.0.1", "*.internal.com"]) }) + test("handles NO_PROXY with trailing comma (#6953)", () => { + process.env.NO_PROXY = "localhost,127.0.0.1," + const config = getProxyConfig() + expect(config.noProxy).toEqual(["localhost", "127.0.0.1"]) + }) + test("config overrides environment variables", () => { process.env.HTTP_PROXY = "http://env-proxy:8080" process.env.HTTPS_PROXY = "http://env-proxy:8443" @@ -127,6 +133,12 @@ describe("proxy-fetch", () => { expect(shouldBypassProxy("notexample.com", ["*.example.com"])).toBe(false) }) + test("matches deep subdomain wildcards (#10710)", () => { + // Issue #10710: *.example.com should match foo.bar.example.com + expect(shouldBypassProxy("foo.bar.example.com", ["*.example.com"])).toBe(true) + expect(shouldBypassProxy("a.b.c.d.example.com", ["*.example.com"])).toBe(true) + }) + test("matches suffix pattern .domain.com", () => { expect(shouldBypassProxy("sub.example.com", [".example.com"])).toBe(true) expect(shouldBypassProxy("deep.sub.example.com", [".example.com"])).toBe(true) From 85aac494a9e706dc07dfa862703fd927482d4cf8 Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Fri, 6 Feb 2026 10:18:19 +0100 Subject: [PATCH 28/29] refactor(skill): use proxyFetch for skill discovery downloads --- packages/opencode/src/skill/discovery.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index a4bf97d7a1b4..53453823444b 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -2,6 +2,7 @@ import path from "path" import { mkdir } from "fs/promises" import { Log } from "../util/log" import { Global } from "../global" +import { proxyFetch } from "../util/fetch" export namespace Discovery { const log = Log.create({ service: "skill-discovery" }) @@ -20,7 +21,7 @@ export namespace Discovery { async function get(url: string, dest: string): Promise { if (await Bun.file(dest).exists()) return true - return fetch(url) + return proxyFetch(url) .then(async (response) => { if (!response.ok) { log.error("failed to download", { url, status: response.status }) @@ -43,7 +44,7 @@ export namespace Discovery { const host = base.slice(0, -1) log.info("fetching index", { url: index }) - const data = await fetch(index) + const data = await proxyFetch(index) .then(async (response) => { if (!response.ok) { log.error("failed to fetch index", { url: index, status: response.status }) From 292afe50a211b96e732753ecdbee1716a214256c Mon Sep 17 00:00:00 2001 From: "Alexandre .D" <20329547+Thesam1798@users.noreply.github.com> Date: Fri, 6 Feb 2026 10:18:54 +0100 Subject: [PATCH 29/29] refactor(codex): use proxyFetch for OAuth headless flow --- packages/opencode/src/plugin/codex.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 20fdf0bcd6c4..f2f183a70bec 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -530,7 +530,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { label: "ChatGPT Pro/Plus (headless)", type: "oauth", authorize: async () => { - const deviceResponse = await fetch(`${ISSUER}/api/accounts/deviceauth/usercode`, { + const deviceResponse = await proxyFetch(`${ISSUER}/api/accounts/deviceauth/usercode`, { method: "POST", headers: { "Content-Type": "application/json", @@ -554,7 +554,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { method: "auto" as const, async callback() { while (true) { - const response = await fetch(`${ISSUER}/api/accounts/deviceauth/token`, { + const response = await proxyFetch(`${ISSUER}/api/accounts/deviceauth/token`, { method: "POST", headers: { "Content-Type": "application/json", @@ -572,7 +572,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { code_verifier: string } - const tokenResponse = await fetch(`${ISSUER}/oauth/token`, { + const tokenResponse = await proxyFetch(`${ISSUER}/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({