diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index 73b3c6d065..152108fd88 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -170,6 +170,8 @@ redirections, les réponses trop volumineuses et les catalogues invalides sont r écriture locale. L'authentification est facultative et lue uniquement par référence à une variable d'environnement (`--auth-env`), jamais depuis argv. +Les requêtes HTTP en loopback sont refusées avant l’ajout des en-têtes d’authentification ou tout envoi si `HTTP_PROXY` ou `http_proxy` s’applique sans exception correspondante dans `NO_PROXY` ou `no_proxy`. `ALL_PROXY`/`all_proxy` et les paramètres limités à `HTTPS_PROXY`/`https_proxy` ne déclenchent pas cette restriction HTTP ; l’acquisition de catalogues en HTTPS reste autorisée. Le message de refus ne contient ni l’adresse du proxy ni le jeton d’authentification. Les valeurs non vides de `http_proxy` et `no_proxy` ont priorité sur `HTTP_PROXY` et `NO_PROXY`, respectivement. Pour des exceptions compatibles avec Bun, utilisez des noms d’hôte, des entrées `host:port` correspondantes, des adresses IPv6 entre crochets comme `[::1]`, ou `*`, sans URL, chemin ni préfixe `*.`. + Le catalogue et le cache sont écrits sous le verrou de catalogue Codex partagé ; un échec préserve les derniers fichiers valides connus. Des octets identiques constituent une non-opération qui préserve les mtimes. `--restart-codex`, `--restart-app-server-only` et l'alias déprécié diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 74f3b3151b..ee7604688b 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -172,6 +172,8 @@ Codex のローカル モデル ピッカー キャッシュを無効にし、 カタログは、ローカル書き込みの前に拒否されます。認証は任意で、環境変数参照 (`--auth-env`) から のみ読み取られ、argv からは読み取られません。 +`HTTP_PROXY` または `http_proxy` が適用され、`NO_PROXY` または `no_proxy` に一致する除外設定がない場合、ループバック HTTP リクエストは認証ヘッダーの付与や送信より前に拒否されます。`ALL_PROXY`/`all_proxy`、または `HTTPS_PROXY`/`https_proxy` だけの設定では、この HTTP 制限は適用されず、HTTPS によるカタログ取得は引き続き許可されます。拒否メッセージにプロキシのアドレスや認証トークンは含まれません。 空でない `http_proxy` と `no_proxy` は、それぞれ `HTTP_PROXY` と `NO_PROXY` より優先されます。Bun に対応する除外ルールには、ホスト名、一致する `host:port`、`[::1]` のように角括弧で囲んだ IPv6 アドレス、または `*` を使い、URL、パス、`*.` 接頭辞は使わないでください。 + カタログとキャッシュは共有の Codex カタログロックの下で書き込まれ、失敗時は last-known-good の ファイルが保持されます。バイトが同一の場合は mtime を保持する no-op です。`--restart-codex`、 `--restart-app-server-only`、非推奨エイリアス `--restart-desktop-app` は、実際の書き込みの後に diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 66ab5f9829..2cac37ed24 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -256,6 +256,8 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 자격증명, 쿼리, 프래그먼트, 리다이렉트, 크기를 넘는 응답, 잘못된 카탈로그는 로컬에 쓰기 전에 거절합니다. 인증은 선택이며 환경변수 이름(`--auth-env`)으로만 읽고 argv로는 받지 않습니다. +`HTTP_PROXY` 또는 `http_proxy`가 적용되고 `NO_PROXY` 또는 `no_proxy`에 일치하는 우회 항목이 없으면 루프백 HTTP 요청은 인증 헤더를 붙이거나 요청을 보내기 전에 거부됩니다. `ALL_PROXY`/`all_proxy` 또는 `HTTPS_PROXY`/`https_proxy`만 설정한 경우에는 이 HTTP 제한에 해당하지 않으며, HTTPS 카탈로그 취득은 계속 허용됩니다. 거부 메시지에는 프록시 주소나 인증 토큰이 포함되지 않습니다. 값이 비어 있지 않은 `http_proxy`와 `no_proxy`는 각각 `HTTP_PROXY`와 `NO_PROXY`보다 우선합니다. Bun과 호환되는 우회 규칙에는 호스트 이름, 일치하는 `host:port`, `[::1]`처럼 대괄호로 감싼 IPv6 주소 또는 `*`를 사용하고, URL·경로·`*.` 접두사는 사용하지 마세요. + 카탈로그와 캐시는 공유 Codex 카탈로그 잠금 아래에서 쓰고, 실패하면 직전까지 정상이던 파일을 그대로 둡니다. 바이트가 같으면 mtime까지 건드리지 않는 no-op입니다. `--restart-codex`, `--restart-app-server-only`, 폐기 예정 별칭 `--restart-desktop-app`은 실제로 쓴 뒤에만 diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index dd1f07c711..32ff382d69 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -304,7 +304,11 @@ before rebuilding the cache. It works even when the local Codex integration desi The URL must be HTTPS; loopback HTTP is accepted for local testing. Embedded URL credentials, queries, fragments, redirects, oversized responses, malformed JSON, duplicate or unsafe slugs, and -unknown `input_modalities` are refused before any local write. Authentication is optional and is +unknown `input_modalities` are refused before any local write. + +Loopback HTTP requests are refused before authentication headers are attached or any request is sent when `HTTP_PROXY` or `http_proxy` applies without a matching `NO_PROXY` or `no_proxy` bypass. `ALL_PROXY`/`all_proxy` and settings limited to `HTTPS_PROXY`/`https_proxy` do not trigger this HTTP restriction; HTTPS catalog acquisition remains allowed. The refusal message includes neither the proxy address nor the authentication token. Nonempty `http_proxy` and `no_proxy` take precedence over `HTTP_PROXY` and `NO_PROXY`, respectively. For Bun-compatible bypass rules, use hostnames, matching `host:port` entries, bracketed IPv6 addresses such as `[::1]`, or `*`; do not use URLs, paths, or `*.` prefixes. + +Authentication is optional and is read only by environment-variable reference: ```bash diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 04315f0e2a..4de2c64624 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -253,6 +253,8 @@ loopback. Учётные данные в URL, query, фрагменты, ред каталоги отклоняются до любой локальной записи. Аутентификация необязательна и читается только по имени переменной окружения (`--auth-env`), но не из argv. +HTTP-запросы к loopback отклоняются до добавления заголовков аутентификации и отправки запроса, если применяется `HTTP_PROXY` или `http_proxy`, а в `NO_PROXY` или `no_proxy` нет подходящего исключения. `ALL_PROXY`/`all_proxy` и настройки только `HTTPS_PROXY`/`https_proxy` не вызывают это ограничение для HTTP; получение каталогов по HTTPS остаётся разрешённым. Сообщение об отказе не содержит адрес прокси или токен аутентификации. Непустые значения `http_proxy` и `no_proxy` имеют приоритет над `HTTP_PROXY` и `NO_PROXY` соответственно. Для совместимых с Bun правил обхода прокси используйте имена хостов, совпадающие записи `host:port`, IPv6-адреса в квадратных скобках, например `[::1]`, или `*`; не используйте URL, пути или префикс `*.`. + Каталог и кэш пишутся под общей блокировкой каталога Codex; при сбое сохраняются last-known-good файлы. Идентичные байты — это no-op, сохраняющий mtime. `--restart-codex`, `--restart-app-server-only` и устаревший alias `--restart-desktop-app` здесь означают то же, что diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index 4e2175bb38..ca59f39db9 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -266,6 +266,8 @@ yanıtlar ve geçersiz kataloglar, herhangi bir yerel yazma işleminden önce re doğrulama isteğe bağlıdır ve yalnızca ortam değişkeni adıyla (`--auth-env`) okunur, argv'den alınmaz. +`HTTP_PROXY` veya `http_proxy` geçerliyken `NO_PROXY` ya da `no_proxy` içinde eşleşen bir istisna yoksa loopback HTTP istekleri, kimlik doğrulama başlıkları eklenmeden ve herhangi bir istek gönderilmeden reddedilir. `ALL_PROXY`/`all_proxy` ve yalnızca `HTTPS_PROXY`/`https_proxy` ayarları bu HTTP kısıtlamasını tetiklemez; HTTPS üzerinden katalog alımına izin verilmeye devam edilir. Ret mesajı proxy adresini veya kimlik doğrulama belirtecini içermez. Boş olmayan `http_proxy` ve `no_proxy` değerleri sırasıyla `HTTP_PROXY` ve `NO_PROXY` değerlerinden önce gelir. Bun ile uyumlu proxy atlama kuralları için ana makine adları, eşleşen `host:port` girdileri, `[::1]` gibi köşeli parantez içindeki IPv6 adresleri veya `*` kullanın; URL, yol veya `*.` öneki kullanmayın. + Katalog ve önbellek, paylaşılan Codex katalog kilidi altında yazılır; bir hata durumunda last-known-good dosyalar korunur. Aynı baytlar, mtime değerlerini koruyan bir no-op'tur. `--restart-codex`, `--restart-app-server-only` ve kullanımdan kaldırılmış takma ad diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 39787e18f0..2c5e3a4915 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -164,6 +164,8 @@ ocx status --json 安装由另一个 OpenCodex 实例的 `/v1/catalog` 端点提供的完整目录,然后同步 `models_cache.json`。URL 必须是 HTTPS;仅回环地址允许 HTTP。URL 内嵌凭据、查询、片段、重定向、超出大小的响应以及无效目录,都会在任何本地写入之前被拒绝。认证是可选的,并且只通过环境变量名(`--auth-env`)读取,不接受 argv 传入。 +如果 `HTTP_PROXY` 或 `http_proxy` 生效,且 `NO_PROXY` 或 `no_proxy` 中没有匹配的绕过规则,回环 HTTP 请求会在添加认证标头或发送请求之前被拒绝。`ALL_PROXY`/`all_proxy` 以及仅设置 `HTTPS_PROXY`/`https_proxy` 的情况不会触发此 HTTP 限制;仍允许通过 HTTPS 获取目录。拒绝消息不会包含代理地址或认证令牌。 非空的 `http_proxy` 和 `no_proxy` 分别优先于 `HTTP_PROXY` 和 `NO_PROXY`。要设置与 Bun 兼容的代理绕过规则,请使用主机名、匹配的 `host:port`、`[::1]` 等带方括号的 IPv6 地址或 `*`,不要使用 URL、路径或 `*.` 前缀。 + 目录和缓存在共享的 Codex 目录锁下写入;失败时保留 last-known-good 文件。字节完全相同时是保留 mtime 的空操作。`--restart-codex`、`--restart-app-server-only` 以及已弃用别名 `--restart-desktop-app` 仅在发生真实写入之后生效,含义与 `ocx sync` / `ocx sync-cache` 相同。`ETag` 条件请求不属于此命令。完整的 `--json` 信封与退出码请参见[英文参考](/reference/cli/lifecycle/)。 ## 后台服务 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 02421b11bd..117133bc18 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -158,6 +158,8 @@ ocx status --json 安裝由另一個 OpenCodex 執行個體的 `/v1/catalog` 端點提供的完整目錄,接著同步 `models_cache.json`。URL 必須是 HTTPS;僅回送位址允許 HTTP。URL 內嵌憑證、查詢、片段、重新導向、超出大小的回應以及無效目錄,都會在任何本機寫入之前遭拒。驗證為選用,且只透過環境變數名稱(`--auth-env`)讀取,不接受 argv 傳入。 +如果 `HTTP_PROXY` 或 `http_proxy` 生效,且 `NO_PROXY` 或 `no_proxy` 中沒有相符的略過規則,回送 HTTP 要求會在加入驗證標頭或送出要求之前遭拒。`ALL_PROXY`/`all_proxy` 以及僅設定 `HTTPS_PROXY`/`https_proxy` 的情況不會觸發此 HTTP 限制;仍允許透過 HTTPS 取得目錄。拒絕訊息不會包含代理位址或驗證權杖。 非空的 `http_proxy` 和 `no_proxy` 分別優先於 `HTTP_PROXY` 和 `NO_PROXY`。若要設定與 Bun 相容的代理略過規則,請使用主機名稱、相符的 `host:port`、`[::1]` 等含方括號的 IPv6 位址或 `*`,不要使用 URL、路徑或 `*.` 前綴。 + 目錄與快取在共用的 Codex 目錄鎖之下寫入;失敗時保留 last-known-good 檔案。位元組完全相同時是保留 mtime 的無操作。`--restart-codex`、`--restart-app-server-only` 以及已棄用別名 `--restart-desktop-app` 僅在實際寫入之後生效,含義與 `ocx sync` / `ocx sync-cache` 相同。`ETag` 條件式請求不屬於此命令。完整的 `--json` 信封與結束碼請參見[英文參考](/reference/cli/lifecycle/)。 ## 背景服務 diff --git a/src/codex/catalog/remote.ts b/src/codex/catalog/remote.ts index 46576b18e4..9a6b534cda 100644 --- a/src/codex/catalog/remote.ts +++ b/src/codex/catalog/remote.ts @@ -113,12 +113,42 @@ function safeTimeout(value: number | undefined): number { ? Math.min(Math.floor(value), 120_000) : DEFAULT_TIMEOUT_MS; } +/** Match Bun fetch's environment routing, not the broader WebSocket NO_PROXY grammar. */ +function catalogRequestUsesBunHttpProxy(url: URL): boolean { + if (url.protocol !== "http:") return false; + const proxy = process.env.http_proxy || process.env.HTTP_PROXY; + if (!proxy || proxy === '""' || proxy === "''") return false; + const hostname = url.hostname.toLowerCase(); + const host = url.host.toLowerCase(); + // Bun env_loader::is_no_proxy (1.4.2): lowercase wins unless empty, ASCII + // whitespace only, no scheme/path/wildcard/bracket/trailing-dot normalization. + const bypasses = process.env.no_proxy || process.env.NO_PROXY || ""; + for (let entry of bypasses.split(",")) { + entry = entry.replace(/^[ \t\n\r\v\f]+|[ \t\n\r\v\f]+$/g, "") + .replace(/[A-Z]/g, letter => letter.toLowerCase()); + if (entry === "*") return false; + if (entry.startsWith(".")) entry = entry.slice(1); + if (!entry) continue; + const hasPort = entry.startsWith("[") + ? entry.includes("]:") + : (entry.match(/:/g)?.length ?? 0) === 1; + if (hasPort ? host === entry : hostname === entry || hostname.endsWith(`.${entry}`)) return false; + } + return true; +} + export async function fetchRemoteCatalog( input: string, options: Pick = {}, ): Promise<{ document: RemoteCatalogDocument; content: string }> { const url = validateRemoteCatalogUrl(input); const token = validateToken(options.token); + if (catalogRequestUsesBunHttpProxy(url)) { + throw new RemoteCatalogError( + "insecure_http_refused", + "Loopback HTTP catalog requests must bypass outbound HTTP proxy routing", + ); + } const headers = new Headers({ Accept: "application/json" }); if (token !== undefined) headers.set("Authorization", `Bearer ${token}`); let response: Response; diff --git a/structure/catalog.md b/structure/catalog.md index 2fd03722df..d2e25f8cfc 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -8,6 +8,10 @@ is scoped to canonical ChatGPT Responses forwarding; other source-area behavior Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). +## Remote catalog HTTP proxy routing + +`src/codex/catalog/remote.ts` permits loopback HTTP only when Bun fetch has no effective HTTP proxy or a matching NO_PROXY bypass. Its local matcher follows [Bun fetch semantics](https://github.com/oven-sh/bun/blob/744846f844374847c902b5e7fd59b4342a51ef99/src/dotenv/env_loader.rs#L369), including non-empty lowercase-variable priority, ASCII whitespace, literal host/port comparison and bracket-preserving IPv6. It does not normalize URL-shaped bypass entries, paths, wildcard prefixes, trailing dots or Unicode whitespace, and leaves the broader WebSocket proxy grammar unchanged. It refuses before authentication headers and fetch with a content-free `insecure_http_refused` error. ALL_PROXY and HTTPS-only settings do not affect HTTP acquisition; HTTPS and existing redirect, size, validation and coordinated-installation contracts are preserved. `tests/codex-integration/catalog-remote-pull.test.ts` covers these routing and non-disclosure boundaries. + ## Shared catalog `src/codex/catalog.ts` builds a shared Codex-shaped catalog for CLI, TUI, App, and SDK. It: diff --git a/structure/codex-home.md b/structure/codex-home.md index 339358ea9b..e904a09609 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -1,5 +1,7 @@ # Codex Home +Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). + ## Codex home `src/codex/paths.ts` resolves Codex state from `CODEX_HOME` when set and valid, otherwise from diff --git a/structure/config.md b/structure/config.md index d00b36b06b..25158ffc05 100644 --- a/structure/config.md +++ b/structure/config.md @@ -1,5 +1,7 @@ # Config Surface +Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). + The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 4628ca5e50..a7fa06c220 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -4,7 +4,7 @@ The shared server request path follows the Responses [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). ## Dashboard serving diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 0c0ffe38d0..b9fb1ad145 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -1,5 +1,7 @@ # Docs And Release +Catalog HTTP acquisition follows the [proxy-routing contract](../catalog.md#remote-catalog-http-proxy-routing). + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index ab7511c3b0..7d48172bad 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -1,5 +1,7 @@ # OpenAI Provider Account Modes +Catalog HTTP acquisition follows the [proxy-routing contract](../catalog.md#remote-catalog-http-proxy-routing). + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/runtime.md b/structure/runtime.md index bd9ebbd561..39dfdeaf08 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -3,6 +3,8 @@ Responses admission and finalization are composed through the [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. +Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). + The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/subagents.md b/structure/subagents.md index c0c92891f7..e55cb42576 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -3,6 +3,8 @@ Encrypted-task and fallback request handling follow the Responses [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. +Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). + ## Plaintext V2 agent messages `src/responses/plaintext-v2-agent-messages.ts` owns the experimental, configuration-only diff --git a/tests/codex-integration/catalog-remote-pull.test.ts b/tests/codex-integration/catalog-remote-pull.test.ts index 02a6768f22..e92e0ccdf6 100644 --- a/tests/codex-integration/catalog-remote-pull.test.ts +++ b/tests/codex-integration/catalog-remote-pull.test.ts @@ -29,6 +29,25 @@ const response = (value: unknown, init: ResponseInit = {}) => new Response(JSON. headers: { "Content-Type": "application/json", ...init.headers }, status: init.status, }); +const proxyEnvKeys = [ + "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", + "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy", +] as const; + +async function withProxyEnv(env: Record, action: () => Promise): Promise { + const previous = proxyEnvKeys.map(key => [key, process.env[key]] as const); + try { + for (const key of proxyEnvKeys) delete process.env[key]; + for (const [key, value] of Object.entries(env)) process.env[key] = value; + await action(); + } finally { + for (const key of proxyEnvKeys) delete process.env[key]; + for (const [key, value] of previous) { + if (value !== undefined) process.env[key] = value; + } + } +} + describe("remote catalog acquisition", () => { test("accepts HTTPS and loopback HTTP but rejects credentials and insecure remote HTTP", () => { expect(validateRemoteCatalogUrl("https://hub.example.com/v1/catalog").href).toBe("https://hub.example.com/v1/catalog"); @@ -53,6 +72,175 @@ describe("remote catalog acquisition", () => { })).rejects.toMatchObject({ code: "redirect_refused", message: "Remote catalog redirect was refused" }); }); + test("refuses proxied loopback HTTP before fetch without disclosing authentication or proxy details", async () => { + const proxy = "http://proxy-user:proxy-secret@127.0.0.2:8080"; + const environments: Record[] = [ + { HTTP_PROXY: proxy }, + { http_proxy: proxy }, + { HTTP_PROXY: "", http_proxy: proxy }, + { HTTP_PROXY: proxy, NO_PROXY: "elsewhere.example" }, + { HTTP_PROXY: proxy, NO_PROXY: "127.0.0.1:9999" }, + { HTTP_PROXY: proxy, NO_PROXY: "http://127.0.0.1" }, + { HTTP_PROXY: proxy, NO_PROXY: "127.0.0.1/path" }, + { HTTP_PROXY: proxy, NO_PROXY: "*.127.0.0.1" }, + { HTTP_PROXY: proxy, NO_PROXY: "127.0.0.1." }, + { HTTP_PROXY: proxy, NO_PROXY: "\u00a0127.0.0.1\u00a0" }, + { HTTP_PROXY: proxy, NO_PROXY: "127.0.0.1", no_proxy: "elsewhere.example" }, + { HTTP_PROXY: proxy, NO_PROXY: "127.0.0.1", no_proxy: " " }, + ]; + for (const env of environments) { + await withProxyEnv(env, async () => { + const fetchImpl = mock(async () => response(catalog)) as typeof fetch; + const error: unknown = await fetchRemoteCatalog("http://127.0.0.1:10100/v1/catalog", { + token: "catalog-token-marker", fetchImpl, + }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(RemoteCatalogError); + expect(error).toMatchObject({ code: "insecure_http_refused" }); + expect(fetchImpl).not.toHaveBeenCalled(); + for (const marker of ["catalog-token-marker", "proxy-user", "proxy-secret", "127.0.0.2", "127.0.0.1"]) { + expect(String(error)).not.toContain(marker); + } + }); + } + }); + + test("permits direct loopback HTTP with matching proxy bypasses or fetch-irrelevant proxy variables", async () => { + const proxy = "http://proxy.example:8080"; + const environments: Record[] = [ + {}, + { HTTP_PROXY: proxy, NO_PROXY: "127.0.0.1" }, + { http_proxy: proxy, no_proxy: "127.0.0.1" }, + { HTTP_PROXY: proxy, NO_PROXY: "127.0.0.1:10100" }, + { HTTP_PROXY: proxy, NO_PROXY: "*" }, + { HTTP_PROXY: proxy, NO_PROXY: ".127.0.0.1" }, + { HTTP_PROXY: proxy, NO_PROXY: "elsewhere.example", no_proxy: "127.0.0.1" }, + { HTTP_PROXY: proxy, NO_PROXY: "\v\f127.0.0.1\r\n" }, + { HTTP_PROXY: '""' }, + { http_proxy: "''" }, + { HTTP_PROXY: proxy, http_proxy: '""' }, + { ALL_PROXY: proxy }, + { all_proxy: proxy }, + { HTTPS_PROXY: proxy }, + { https_proxy: proxy }, + ]; + for (const env of environments) { + await withProxyEnv(env, async () => { + const fetchImpl = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer catalog-token-marker"); + expect(init?.redirect).toBe("manual"); + return response(catalog); + }) as typeof fetch; + await expect(fetchRemoteCatalog("http://127.0.0.1:10100/v1/catalog", { + token: "catalog-token-marker", fetchImpl, + })).resolves.toMatchObject({ document: catalog }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + } + }); + + test.each([ + ["http://127.0.0.1", false], + ["127.0.0.1", true], + ] as const)("real Bun transport respects the catalog guard for NO_PROXY=%s", async (bypass, direct) => { + let targetRequests = 0; + let proxyRequests = 0; + let authenticatedTargetRequests = 0; + const token = "synthetic-catalog-runtime-token"; + let target: ReturnType | undefined; + let proxy: ReturnType | undefined; + let child: Bun.Subprocess<"ignore", "pipe", "pipe"> | undefined; + let timer: ReturnType | undefined; + let timedOut = false; + try { + target = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(req) { + targetRequests += 1; + if (req.headers.get("authorization") === `Bearer ${token}`) authenticatedTargetRequests += 1; + return response(catalog); + } }); + proxy = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch() { + proxyRequests += 1; + return response(catalog); + } }); + // Inherit process-launch necessities and test provenance only, never host credentials. + const env: Record = {}; + for (const key of ["PATH", "Path", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT", "TEMP", "TMP", + "OCX_TEST_HOME_GUARD", "OCX_TEST_RUN_ID"]) { + const value = process.env[key]; + if (value !== undefined) env[key] = value; + } + for (const key of proxyEnvKeys) delete env[key]; + env.OPENCODEX_HOME = home(); + env.CODEX_HOME = home(); + env.HOME = env.USERPROFILE = home(); + // A local dotenv must not override the explicitly supplied routing fixture. + writeFileSync(join(env.OPENCODEX_HOME, ".env"), "no_proxy=*\n"); + env.HTTP_PROXY = `http://127.0.0.1:${proxy.port}`; + env.NO_PROXY = bypass; + const source = new URL("../../src/codex/catalog/remote.ts", import.meta.url).href; + const script = ` + const { fetchRemoteCatalog } = await import(${JSON.stringify(source)}); + try { + const result = await fetchRemoteCatalog(${JSON.stringify(`http://127.0.0.1:${target.port}/v1/catalog`)}, + { token: ${JSON.stringify(token)} }); + console.log(JSON.stringify({ document: result.document })); + } catch (error) { + console.log(JSON.stringify({ code: error?.code ?? "unexpected_error" })); + } + `; + child = Bun.spawn([process.execPath, "--no-env-file", "--eval", script], { cwd: env.OPENCODEX_HOME, env, stdin: "ignore", stdout: "pipe", stderr: "pipe" }); + timer = setTimeout(() => { timedOut = true; child?.kill("SIGKILL"); }, 10_000); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), + ]); + const evidence = JSON.stringify({ exitCode, timedOut, targetRequests, proxyRequests, stdout, stderr }); + expect(timedOut, evidence).toBe(false); + expect(exitCode, evidence).toBe(0); + expect(proxyRequests, evidence).toBe(0); + expect(targetRequests, evidence).toBe(direct ? 1 : 0); + expect(authenticatedTargetRequests, evidence).toBe(direct ? 1 : 0); + expect(JSON.parse(stdout)).toEqual(direct ? { document: catalog } : { code: "insecure_http_refused" }); + } finally { + if (timer !== undefined) clearTimeout(timer); + if (child && child.exitCode === null) { child.kill("SIGKILL"); await child.exited; } + await proxy?.stop(true); + await target?.stop(true); + } + }, 15_000); + + test("keeps authenticated HTTPS acquisition available with an outbound proxy", async () => { + await withProxyEnv({ HTTP_PROXY: "http://proxy.example:8080", HTTPS_PROXY: "http://proxy.example:8080" }, async () => { + const fetchImpl = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer catalog-token-marker"); + expect(init?.redirect).toBe("manual"); + return response(catalog); + }) as typeof fetch; + await expect(fetchRemoteCatalog("https://hub.example/v1/catalog", { + token: "catalog-token-marker", fetchImpl, + })).resolves.toMatchObject({ document: catalog }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + }); + + test.each([ + ["http://[::1]:10100/v1/catalog", "::1", false], + ["http://[::1]:10100/v1/catalog", "[::1]", true], + ["http://[::1]:10100/v1/catalog", "[::1]:10100", true], + ["http://[::1]:10100/v1/catalog", "[::1]:9999", false], + ["http://127.0.0.1/v1/catalog", "127.0.0.1:80", false], + ] as const)("uses Bun's literal host/port bypass for %s and %s", async (url, bypass, direct) => { + await withProxyEnv({ HTTP_PROXY: "http://proxy.example:8080", NO_PROXY: bypass }, async () => { + const fetchImpl = mock(async () => response(catalog)) as typeof fetch; + const result = fetchRemoteCatalog(url, { token: "catalog-token-marker", fetchImpl }); + if (direct) { + await expect(result).resolves.toMatchObject({ document: catalog }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + } else { + await expect(result).rejects.toMatchObject({ code: "insecure_http_refused" }); + expect(fetchImpl).not.toHaveBeenCalled(); + } + }); + }); + test("never reflects credentials, remote bodies, URLs, or transport causes", async () => { for (const fetchImpl of [ async () => new Response("remote-body-marker", { status: 401 }),