diff --git a/packages/opencode/docs/proxy.md b/packages/opencode/docs/proxy.md new file mode 100644 index 000000000000..5b473c10ca3e --- /dev/null +++ b/packages/opencode/docs/proxy.md @@ -0,0 +1,226 @@ +# 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 (any non-empty value) | `1` | + +## 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. 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 + +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 +``` 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", diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6dd0592d51e3..1fbf2a7c66df 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, setProxyConfig } 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}`) } @@ -235,6 +236,9 @@ export namespace Config { result.plugin = deduplicatePlugins(result.plugin ?? []) + // Wire proxy config to fetch layer + setProxyConfig(result.proxy) + return { config: result, directories, @@ -999,6 +1003,34 @@ 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)"), + 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({ + 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 +1129,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), 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() 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() diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index b0755b8b563c..ba391a04e23a 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") @@ -581,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) @@ -637,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 @@ -685,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 @@ -932,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 @@ -978,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 @@ -1253,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 @@ -1388,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 @@ -1436,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 @@ -1656,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 @@ -1687,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 @@ -1746,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 @@ -1777,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 @@ -1945,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 @@ -1983,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 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 diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index c8b833baeca3..f2f183a70bec 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, }) @@ -529,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", @@ -553,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", @@ -571,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({ 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", 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, }, 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 : "")), 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()) 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 }) 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), diff --git a/packages/opencode/src/util/fetch.ts b/packages/opencode/src/util/fetch.ts new file mode 100644 index 000000000000..4b9eebdf0fc9 --- /dev/null +++ b/packages/opencode/src/util/fetch.ts @@ -0,0 +1,203 @@ +/** + * 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" +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 = { + rejectUnauthorized?: boolean + ca?: BlobPart | BlobPart[] +} + +type BunFetchInit = RequestInit & { + proxy?: string | { url: string; headers?: Record } + tls?: BunTlsConfig +} + +/** + * Internal proxy configuration state + * Set via setProxyConfig() from opencode.json config + */ +let _configProxy: Config.ProxyConfig | undefined +let _tlsWarningEmitted = false + +/** + * 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 +} + +/** + * 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 && !_tlsWarningEmitted) { + log.warn("TLS certificate validation disabled for proxy - connection vulnerable to MITM attacks") + _tlsWarningEmitted = true + } + } + + 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 + * + * 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?: Omit & { 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) { + const tls = getTlsForProxy() + return fetch(input, { + ...init, + proxy: proxyUrl, + ...(tls && { tls }), + } as RequestInit) + } + + return fetch(input, init) +} + +// Named exports +export { proxyFetch as fetch } +export default proxyFetch 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..a89a6dc57c17 --- /dev/null +++ b/packages/opencode/test/util/fetch-integration.test.ts @@ -0,0 +1,190 @@ +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(() => { + // 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 + 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("disables proxy when flag is any non-empty string", () => { + process.env.HTTPS_PROXY = "http://proxy:8080" + process.env.OPENCODE_DISABLE_PROXY = "0" + + // Any non-empty string (including "0") disables the proxy + 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() + }) + }) +}) diff --git a/packages/opencode/test/util/fetch.test.ts b/packages/opencode/test/util/fetch.test.ts new file mode 100644 index 000000000000..8ae908de09eb --- /dev/null +++ b/packages/opencode/test/util/fetch.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import { getProxyConfig, shouldBypassProxy, getProxyForUrl, setProxyConfig, getTlsForProxy } 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(() => { + // 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) + }) + + 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("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" + 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 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) + 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() + }) + }) + + 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") + }) + }) +})