diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 40b7a179d7..992f097e3d 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -158,6 +158,8 @@ les adresses IPv6 entre crochets et `*` ; par exemple, indiquez explicitement ` restent bloquées. Les requêtes de diagnostic rejettent les redirections et signalent une cible dont les identifiants ont été retirés. L'examen des redirections des requêtes ordinaires vers les fournisseurs reste distinct de cette protection de diagnostic. +Deux accommodements fake-IP DNS existent pour les utilisateurs de Clash / Surge / Mihomo, et tous deux ne s'appliquent qu'aux *réponses* DNS — une adresse littérale dans l'URL reste rejetée. La plage de benchmark IANA `198.18.0.0/15` (et ses écritures IPv6 IPv4-mapped) est acceptée dès qu'un proxy sortant s'applique à l'hôte. La plage IPv6 fake-IP par défaut de Mihomo `fdfe:dcba:9876::/48` est acceptée sous une condition plus stricte : la variable de proxy correspondant au schéma de l'URL (`HTTPS_PROXY` pour `https:`, `HTTP_PROXY` pour `http:` ; `ALL_PROXY` ne compte pas) doit être définie, l'hôte ne doit pas correspondre à `NO_PROXY`, et la requête est alors explicitement liée à ce proxy. Tout autre ULA, un préfixe adjacent ou une réponse fake-IP mélangée à une vraie réponse privée exige toujours `allowPrivateNetwork: true`. La validation à l'enregistrement du fournisseur n'applique jamais l'accommodement IPv6. + ## Groupe de comptes Codex Utilisez **Codex Auth** dans le tableau de bord pour ajouter des comptes au groupe et actualiser les quotas. `config.json` stocke les diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 3b54798ce4..922a77f3c3 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -134,6 +134,8 @@ API キープロバイダーは、リテラルキーまたは環境参照を保 プライベート/ローカル宛先には `allowPrivateNetwork: true` が必要で、送信プロキシがアクティブな場合は、一致する `NO_PROXY` エントリが必要です。ループバックは自動的に追加されます。 CIDR エントリは解釈されないため、各 LAN ホストを明示的にリストします。マッチャーは、正確なホスト、ドメイン サフィックス、オプションのポート、括弧で囲まれた IPv6、および `*` をサポートします。たとえば、`192.168.1.50` を明示的にリストします。メタデータとリンクローカル宛先はブロックされたままになります。診断リクエストはリダイレクトを拒否し、資格情報が剥奪されたターゲットを報告します。通常のプロバイダー要求のリダイレクト レビューは、この診断ガードとは独立したままになります。 +Clash / Surge / Mihomo 利用者向けの fake-IP DNS 例外は 2 種類あり、いずれも DNS の*応答*にのみ適用されます。URL に書かれたリテラルアドレスは引き続き拒否されます。IANA ベンチマーク範囲 `198.18.0.0/15`(IPv4-mapped IPv6 表記を含む)は、そのホストにアウトバウンドプロキシが適用される場合に許可されます。Mihomo の既定 IPv6 fake-IP 範囲 `fdfe:dcba:9876::/48` はより厳しい条件でのみ許可されます。URL スキームに一致するプロキシ変数(`https:` は `HTTPS_PROXY`、`http:` は `HTTP_PROXY`、`ALL_PROXY` は対象外)が設定されていること、ホストが `NO_PROXY` に一致しないことが必要で、その場合リクエストはそのプロキシに明示的に固定されます。それ以外の ULA、隣接プレフィックス、実際のプライベート応答と混在した fake-IP 応答には引き続き `allowPrivateNetwork: true` が必要です。プロバイダー保存時の検証には IPv6 例外は適用されません。 + ## Codexアカウントプール pool アカウントの追加と quota 更新はダッシュボードの **Codex Auth** ページで処理してください。設定には secret で diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 927535c845..da0b770ba2 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -134,6 +134,8 @@ API 키 공급자는 리터럴 키나 환경 참조를 둘 수 있습니다. OAu 사설/로컬 목적지는 `allowPrivateNetwork: true`가 필요하며, 아웃바운드 프록시가 활성화된 경우에는 일치하는 `NO_PROXY` 항목도 필요합니다. loopback은 자동으로 추가됩니다. CIDR 항목은 해석하지 않으므로 각 LAN 호스트는 따로 적어야 합니다. matcher는 정확한 호스트, 도메인 접미사, 선택적 포트, 괄호로 감싼 IPv6, `*`를 지원합니다. 예를 들면 `192.168.1.50`은 따로 적어야 합니다. 메타데이터와 link-local 목적지는 계속 차단됩니다. 진단 요청은 리디렉션을 거부하고, 자격 증명이 제거된 대상만 보고합니다. 일반적인 공급자 요청의 리디렉션 검토는 이 진단 가드와 별도로 유지됩니다. +Clash / Surge / Mihomo 사용자를 위한 fake-IP DNS 예외는 두 가지이며, 둘 다 DNS *응답*에만 적용됩니다. URL에 적힌 리터럴 주소는 그대로 거부됩니다. IANA 벤치마크 대역 `198.18.0.0/15`(IPv4-mapped IPv6 표기 포함)은 해당 호스트에 아웃바운드 프록시가 적용될 때 허용됩니다. Mihomo 기본 IPv6 fake-IP 대역 `fdfe:dcba:9876::/48`은 더 엄격한 조건에서만 허용됩니다. URL 스킴에 맞는 프록시 변수(`https:`는 `HTTPS_PROXY`, `http:`는 `HTTP_PROXY`, `ALL_PROXY`는 해당 없음)가 설정되어 있어야 하고, 호스트가 `NO_PROXY`에 걸리지 않아야 하며, 그 경우 요청은 해당 프록시에 명시적으로 묶여 나갑니다. 그 밖의 ULA, 인접 프리픽스, 실제 사설 응답과 섞인 fake-IP 응답은 여전히 `allowPrivateNetwork: true`가 필요합니다. 프로바이더 저장 시점 검증에는 IPv6 예외가 적용되지 않습니다. + ## Codex 계정 풀 pool 계정 추가와 quota 갱신은 대시보드의 **Codex Auth** 페이지에서 처리하세요. 설정에는 secret이 diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 3f3e286863..93c6fa63f3 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -242,18 +242,22 @@ interrupted package update removed either file, it logs one `installation is inc stops instead of retrying the same missing executable every five seconds. Reinstall opencodex, then run `ocx service repair` to refresh the task with the restored package paths. -On Linux, the systemd unit invokes the first regular, executable `ocx` file found on `PATH` at -install time rather than the Bun and CLI paths inside the installed package tree. Version managers such as +On macOS and Linux, the launchd plist and the systemd unit invoke the first regular, executable +`ocx` file found on `PATH` at install time rather than the Bun and CLI paths inside the installed +package tree. Version managers such as **mise** and **asdf** install into a versioned directory and delete the old one on upgrade, which -used to leave the unit pointing at files that no longer existed — systemd then restart-looped while -still reporting the service as installed. A shim path survives the upgrade, so the unit keeps -resolving. Source checkouts without an `ocx` launcher keep the previous direct Bun + CLI form. A +used to leave the service definition pointing at files that no longer existed — systemd then +restart-looped while still reporting the service as installed, and launchd kept the old build serving +until it was restarted by hand. A shim path survives the upgrade, so the definition keeps resolving. Source checkouts without an `ocx` launcher keep the previous direct Bun + CLI form. A trusted `OPENCODEX_BUN_PATH` selected before Bun starts is preserved through the shim; package-local bundled Bun paths are deliberately rediscovered after upgrades instead of being pinned in the unit. -Units installed before this change still carry the old versioned paths and cannot migrate +Definitions installed before this change still carry the old versioned paths and cannot migrate themselves — once the old executable is deleted, no opencodex code runs to fix it. Run -`ocx service repair` once after upgrading; subsequent version changes need no action. +`ocx service repair` once after upgrading; after that, each service start follows the launcher. +An already-running proxy is not replaced by an external upgrade: restart the service (or run +`ocx service repair`) so the new build serves, and treat a CLI/proxy version mismatch warning as +exactly that signal. | Subcommand | Action | | --- | --- | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 77eacc929b..7d6d4fa1d7 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -351,6 +351,16 @@ destinations stay blocked. Diagnostic requests reject redirects and report a credential-stripped target. Ordinary provider request redirect review remains separate from this diagnostic guard. +Two fake-IP DNS accommodations exist for Clash / Surge / Mihomo users, and both apply to DNS +*answers* only — a literal address in the URL is still rejected. The IANA benchmark range +`198.18.0.0/15` (and its IPv4-mapped IPv6 spellings) is accepted whenever an outbound proxy applies +to the host. Mihomo's default IPv6 fake-IP range `fdfe:dcba:9876::/48` is accepted on a stricter +gate: the proxy variable that matches the URL scheme (`HTTPS_PROXY` for `https:`, `HTTP_PROXY` for +`http:`; `ALL_PROXY` does not count) must be set, the host must not match `NO_PROXY`, and the +request is then bound to that proxy explicitly. Any other ULA, an adjacent prefix, or a fake-IP answer +mixed with a real private answer still requires `allowPrivateNetwork: true`. Provider save-time +validation never applies the IPv6 accommodation. + ## Codex account pool Use **Codex Auth** in the dashboard to add pool accounts and refresh quotas. `config.json` stores diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 7dddd9f09c..26ab107b45 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -162,6 +162,8 @@ domain suffix, необязательные порты, IPv6 в квадратн не следуют redirect'ам и в результатах показывают только credential-stripped target. Проверка redirect'ов для обычных provider-request'ов реализована отдельно и к этому guard не относится. +Для пользователей Clash / Surge / Mihomo предусмотрены два исключения fake-IP DNS, и оба применяются только к DNS-*ответам* — литеральный адрес в URL по-прежнему отклоняется. Диапазон IANA benchmark `198.18.0.0/15` (включая IPv4-mapped IPv6 записи) принимается, когда к хосту применяется исходящий прокси. Диапазон IPv6 fake-IP по умолчанию в Mihomo `fdfe:dcba:9876::/48` принимается при более строгом условии: должна быть задана переменная прокси, соответствующая схеме URL (`HTTPS_PROXY` для `https:`, `HTTP_PROXY` для `http:`; `ALL_PROXY` не учитывается), хост не должен совпадать с `NO_PROXY`, и тогда запрос явно привязывается к этому прокси. Любой другой ULA, соседний префикс или fake-IP ответ вперемешку с реальным приватным ответом по-прежнему требуют `allowPrivateNetwork: true`. Валидация при сохранении провайдера никогда не применяет IPv6-исключение. + ## Пул аккаунтов Codex Используйте страницу **Codex Auth** дашборда для добавления аккаунтов пула и обновления квот. diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 22d5c9056b..217c8e2466 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -171,6 +171,8 @@ hedefleri engellenmiş olarak kalır. Teşhis istekleri yönlendirmeleri reddede kimlik bilgisi kaldırılmış bir hedef bildirir. Sıradan sağlayıcı isteği yeniden yönlendirme incelemesi bu teşhis korumasından ayrı kalır. +Clash / Surge / Mihomo kullanıcıları için iki fake-IP DNS istisnası vardır ve ikisi de yalnızca DNS *yanıtlarına* uygulanır; URL'deki literal adres yine reddedilir. IANA benchmark aralığı `198.18.0.0/15` (IPv4-mapped IPv6 yazımları dahil), ana bilgisayara bir giden proxy uygulandığında kabul edilir. Mihomo'nun varsayılan IPv6 fake-IP aralığı `fdfe:dcba:9876::/48` daha sıkı bir koşulla kabul edilir: URL şemasıyla eşleşen proxy değişkeni (`https:` için `HTTPS_PROXY`, `http:` için `HTTP_PROXY`; `ALL_PROXY` sayılmaz) ayarlı olmalı, ana bilgisayar `NO_PROXY` ile eşleşmemeli ve istek daha sonra açıkça o proxy'ye bağlanır. Diğer tüm ULA'lar, komşu önekler veya gerçek bir özel yanıtla karışık fake-IP yanıtları hâlâ `allowPrivateNetwork: true` gerektirir. Sağlayıcı kaydetme zamanı doğrulaması IPv6 istisnasını hiçbir zaman uygulamaz. + ## Codex hesap havuzu Havuz hesapları eklemek ve kotaları yenilemek için kontrol panelinde **Codex diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 7c4c873ac8..fe3b8cafe5 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -134,6 +134,8 @@ API key 提供者可以持有字面量 key,或环境引用。OAuth 提供者 私有/本地目标需要 `allowPrivateNetwork: true`,并且在出站代理启用时,还需要匹配的 `NO_PROXY` 条目。回环地址会自动加入;每个 LAN 主机都必须显式列出,因为 CIDR 条目不会被解释。匹配器支持精确主机、域后缀、可选端口、带方括号的 IPv6 以及 `*`;例如,应显式列出 `192.168.1.50`。元数据和链路本地目标仍会被阻止。诊断请求会拒绝重定向,并报告一个已剥离凭据的目标。普通提供者请求的重定向审查仍然独立于这个诊断保护。 +面向 Clash / Surge / Mihomo 用户的 fake-IP DNS 例外有两种,且都只作用于 DNS *应答*——URL 中的字面地址仍会被拒绝。IANA 基准段 `198.18.0.0/15`(含 IPv4-mapped IPv6 写法)在该主机适用出站代理时被接受。Mihomo 默认的 IPv6 fake-IP 段 `fdfe:dcba:9876::/48` 采用更严格的门槛:必须设置与 URL 协议匹配的代理变量(`https:` 对应 `HTTPS_PROXY`,`http:` 对应 `HTTP_PROXY`,`ALL_PROXY` 不算),主机不能命中 `NO_PROXY`,随后请求会被显式绑定到该代理。其他 ULA、相邻前缀,或与真实私网应答混合的 fake-IP 应答仍需要 `allowPrivateNetwork: true`。提供方保存时的校验不应用该 IPv6 例外。 + ## Codex 账户池 请在仪表盘 **Codex Auth** 页面添加 pool account 并刷新 quota。配置只保存非 secret account diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 43001e1ccd..32511b66b0 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -108,6 +108,8 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 私有/本機目的地需要 `allowPrivateNetwork: true`,且當對外代理活躍時需要相符的 `NO_PROXY` 項目。回送會自動加入;請明確列出每個 LAN 主機,因為 CIDR 項目不被解讀。比對器支援精確主機、網域後綴、可選連接埠、方括號 IPv6 與 `*`;例如,明確列出 `192.168.1.50`。中繼資料與 link-link 目標保持被封鎖。診斷請求拒絕重新導向並回報已剝離憑證的目標。普通供應商請求的重新導向審查與此診斷防護分開。 +針對 Clash / Surge / Mihomo 使用者的 fake-IP DNS 例外有兩種,且都只作用於 DNS *回應*——URL 中的字面位址仍會被拒絕。IANA 基準區段 `198.18.0.0/15`(含 IPv4-mapped IPv6 寫法)在該主機適用對外代理時被接受。Mihomo 預設的 IPv6 fake-IP 區段 `fdfe:dcba:9876::/48` 採更嚴格的門檻:必須設定與 URL 協定相符的代理變數(`https:` 對應 `HTTPS_PROXY`,`http:` 對應 `HTTP_PROXY`,`ALL_PROXY` 不算),主機不得命中 `NO_PROXY`,之後請求會被明確綁定到該代理。其他 ULA、相鄰前綴,或與真實私網回應混合的 fake-IP 回應仍需要 `allowPrivateNetwork: true`。提供者儲存時的驗證不套用此 IPv6 例外。 + ## Codex 帳號池 在儀表板中使用 **Codex Auth** 新增池帳號並重新整理配額。`config.json` 儲存非秘密中繼資料;access 與 refresh token 使用強化的憑證存放。池路由將新/未綁定指派、基於用量的主動切換與失敗復原分開。綁定任務通常保留親和性,但 `quota` 可在其超過用量閾值後的下一個請求時重新綁定它,而暫停、冷卻、重新認證與失敗處理可獨立清除或移動路由。未綁定請求沒有即時帳號綁定;這可包含代理重啟或親和性重置後的既有可見任務。Pre-stream 的 429 或 402 在同一個請求中於一個合格的備用帳號上重試一次,即使基於用量的主動切換關閉。帳號變更保留並重播對話 context,但跨帳號的供應商端 prompt-cache 重用不保證,cache 可能需要重新暖機。 diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts index 10dabbf8bf..797bac5fe0 100644 --- a/src/lib/destination-policy.ts +++ b/src/lib/destination-policy.ts @@ -145,6 +145,25 @@ function isBenchmarkDnsAnswer(address: string, assessment: DestinationAssessment return embedded.kind === "private" && embedded.detail === "benchmark address"; } +/** + * Mihomo (Clash.Meta) fake-IP DNS answers IPv6 queries from `fdfe:dcba:9876::/48` — its + * documented default `fake-ip-range6` (#3462). That prefix sits inside ULA `fc00::/7`, so + * `classifyIpv6` reports it as a private-network address and, unlike the IPv4 benchmark + * range, nothing about the address itself marks it synthetic. The exception is therefore + * narrower than the benchmark one: exact /48 match, DNS answers only (a literal URL still + * rejects), and only behind the `allowMihomoIpv6FakeIp` opt-in that the outbound caller + * derives from a scheme-matched proxy it then binds the request to. + */ +const MIHOMO_IPV6_FAKE_IP_PREFIX = [0xfdfe, 0xdcba, 0x9876] as const; + +function isMihomoIpv6FakeIpAnswer(address: string, assessment: DestinationAssessment | null): boolean { + if (assessment?.kind !== "private" || assessment.detail !== "private-network address") return false; + if (isIP(address) !== 6) return false; + const hextets = ipv6Hextets(normalizeHostname(address)); + if (!hextets) return false; + return MIHOMO_IPV6_FAKE_IP_PREFIX.every((group, index) => hextets[index] === group); +} + function firstIpv6Hextet(hostname: string): number | null { const head = hostname.split(":")[0]; if (!head) return 0; @@ -385,7 +404,13 @@ export function assessUrlDestination(url: string): UrlDestinationAssessment | nu */ export async function resolvePublicAddresses( url: string, - options?: string | { context?: string; allowPrivateNetwork?: boolean; allowBenchmarkAddresses?: boolean }, + options?: string | { + context?: string; + allowPrivateNetwork?: boolean; + allowBenchmarkAddresses?: boolean; + /** Mihomo IPv6 fake-IP (`fdfe:dcba:9876::/48`) DNS answers; see `isMihomoIpv6FakeIpAnswer`. */ + allowMihomoIpv6FakeIp?: boolean; + }, ): Promise<{ hostname: string; addresses: { address: string; family: number }[]; @@ -396,6 +421,7 @@ export async function resolvePublicAddresses( : options?.context?.trim() || "image URL"; const privateNetworkAllowed = typeof options === "object" && options?.allowPrivateNetwork === true; const benchmarkAllowed = typeof options === "object" && options?.allowBenchmarkAddresses === true; + const mihomoIpv6Allowed = typeof options === "object" && options?.allowMihomoIpv6FakeIp === true; let hostname: string; try { hostname = normalizeHostname(new URL(url.trim()).hostname); @@ -440,7 +466,10 @@ export async function resolvePublicAddresses( // fake-IP DNS, not a LAN provider. Accept it without allowPrivateNetwork and // do not mark the destination private, so the caller's HTTP(S)_PROXY path // still applies (credit #1748). - if (benchmarkAllowed && isBenchmarkDnsAnswer(address, assessment)) { + if ( + (benchmarkAllowed && isBenchmarkDnsAnswer(address, assessment)) + || (mihomoIpv6Allowed && isMihomoIpv6FakeIpAnswer(address, assessment)) + ) { validatedAddresses.push({ address, family: ipKind === 4 || ipKind === 6 ? ipKind : (family || 4) }); continue; } diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index f067aa927d..bfbce5f508 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -7,7 +7,7 @@ import { resolvePublicAddresses, } from "./destination-policy"; import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http"; -import { outboundProxyConfigured } from "./proxy-env"; +import { effectiveProxyFor, outboundProxyConfigured } from "./proxy-env"; import { publicProviderBaseUrl } from "./provider-url"; type ProviderGetInit = Omit; @@ -139,6 +139,11 @@ async function providerOutboundRequest( } const parsed = postUrl ?? new URL(url); const proxyConfigured = configuredProxyFor(); + // Snapshot the scheme-matched proxy once, before the DNS await, so admission and transport + // below reason about the same value. `null` here means "no proxy fetch would actually use", + // even if some other proxy variable is set. + const effectiveProxy = effectiveProxyFor(parsed); + const allowMihomoIpv6FakeIp = effectiveProxy !== null && !noProxyMatches(parsed); const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses; const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet; const pinnedPost = dependencies.pinnedPost ?? pinnedHttpPost; @@ -155,6 +160,12 @@ async function providerOutboundRequest( // match is a direct route, so it keeps the benchmark answer rejected. Image/Lab // fetch never passes this flag. allowBenchmarkAddresses: proxyConfigured && !noProxyMatches(parsed), + // Mihomo IPv6 fake-IP (fdfe:dcba:9876::/48) answers are admitted on a stricter gate + // than the benchmark range: the proxy must be the one fetch will use for this URL's + // scheme, and the request below is then bound to it explicitly (#3462). A ULA answer + // is otherwise indistinguishable from a real private host, so proxy presence alone + // is not enough. + allowMihomoIpv6FakeIp, }); } catch (error) { const dnsResolutionFailed = error instanceof DestinationDnsResolutionError @@ -169,7 +180,10 @@ async function providerOutboundRequest( } if (proxyConfigured && !resolved.privateNetwork) { warnProxyBoundaryOnce(); - return globalThis.fetch(url, { ...init, method, redirect: "manual" }); + // When the Mihomo exception could have admitted an answer, pin the transport to the + // proxy the admission assumed instead of letting fetch re-infer it from the environment. + const proxy = allowMihomoIpv6FakeIp ? effectiveProxy : undefined; + return globalThis.fetch(url, { ...init, method, redirect: "manual", ...(proxy ? { proxy } : {}) }); } if (proxyConfigured && resolved.privateNetwork && !noProxyMatches(parsed)) { const hostname = normalizeProxyHostname(parsed.hostname); diff --git a/src/lib/proxy-env.ts b/src/lib/proxy-env.ts index d34688ab95..46df592689 100644 --- a/src/lib/proxy-env.ts +++ b/src/lib/proxy-env.ts @@ -16,3 +16,25 @@ export function outboundProxyConfigured( ): boolean { return OUTBOUND_PROXY_ENV_KEYS.some(key => proxyEnvPresent(key, env)); } + +/** + * The proxy URL that Bun's fetch will actually use for `url`, or null when none applies. + * + * Bun selects by scheme: `HTTPS_PROXY` for `https:` targets, `HTTP_PROXY` for `http:`. + * `ALL_PROXY` is deliberately not consulted here — fetch does not honour it, so a caller + * that needs "this request will ride the proxy" as a precondition must not count it. + * Presence of *some* proxy variable (`outboundProxyConfigured`) is not that guarantee. + */ +export function effectiveProxyFor( + url: URL, + env: ProxyEnvMap = process.env, +): string | null { + const key: ProxyEnvKey | null = url.protocol === "https:" + ? "HTTPS_PROXY" + : url.protocol === "http:" + ? "HTTP_PROXY" + : null; + if (!key) return null; + const value = env[key]?.trim() || env[key.toLowerCase()]?.trim(); + return value ? value : null; +} diff --git a/src/service.ts b/src/service.ts index 84b7da3817..b37e88c3db 100644 --- a/src/service.ts +++ b/src/service.ts @@ -205,9 +205,9 @@ export interface ServiceInstallState { bunPath?: string; cliPath?: string; /** - * Linux only. The stable `ocx` launcher the unit actually invokes, when one was found. - * Present means `bunPath`/`cliPath` are provenance for the install, NOT what systemd - * runs — so staleness must be judged against THIS path instead. A version-manager + * launchd and systemd. The stable `ocx` launcher the service definition actually invokes, + * when one was found. Present means `bunPath`/`cliPath` are provenance for the install, + * NOT what the service runs — so staleness must be judged against THIS path instead. A version-manager * upgrade replaces the directory those two point into while the launcher survives, and * checking the old pair would report a stale service that is in fact healthy. */ @@ -486,8 +486,21 @@ function writeServiceApiTokenFile(): string | null { return path; } -export function buildPlist(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string { - const { bun, bunRuntimeSource, cli } = cliEntry(); +/** + * Render the launchd plist. Mirrors `buildUnit`: when `deps.launcher` names a stable `ocx` + * executable, the job execs that launcher instead of the package-local Bun + CLI pair, so a + * version-manager upgrade (mise, asdf, nvm) that replaces the package directory is picked up + * on the next launchd start instead of leaving the old build serving (#3464 — the macOS + * counterpart of #2898). Discovery belongs to `installLaunchd()`; the default here is the + * legacy pair so callers and tests stay hermetic. + */ +export function buildPlist( + proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(), + deps: { launcher?: string | null; runtime?: DurableBunRuntime } = {}, +): string { + const runtime = deps.runtime ?? durableBunRuntime(); + const { bun, bunRuntimeSource, cli } = cliEntry(runtime); + const launcher = deps.launcher ?? null; const log = logPath(); const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; const codexHome = process.env.CODEX_HOME?.trim(); @@ -495,8 +508,16 @@ export function buildPlist(proxyEnv: { name: string; value: string }[] = resolve const opencodexHome = process.env.OPENCODEX_HOME?.trim(); const envLines = [ ` OCX_SERVICE1`, - ` ${BUN_RUNTIME_SOURCE_ENV}${bunRuntimeSource}`, - ` ${BUN_RUNTIME_PATH_ENV}${plistString(bun)}`, + ...(launcher ? [] : [ + ` ${BUN_RUNTIME_SOURCE_ENV}${bunRuntimeSource}`, + ` ${BUN_RUNTIME_PATH_ENV}${plistString(bun)}`, + ]), + // A launcher resolves the current package's bundled Bun after every upgrade. Preserve + // only a proof-bound shell override; baking a package-local path here would recreate + // the version-manager pin that launcher mode exists to remove (same rule as buildUnit). + launcher && runtime.source === "override" + ? ` ${runtime.overrideEnv}${plistString(runtime.path)}` + : null, ` PATH${plistString(path)}`, codexHome ? ` CODEX_HOME${plistString(codexHome)}` : null, codexSqliteHome ? ` CODEX_SQLITE_HOME${plistString(codexSqliteHome)}` : null, @@ -504,7 +525,9 @@ export function buildPlist(proxyEnv: { name: string; value: string }[] = resolve ...proxyEnv.map(({ name, value }) => ` ${name}${plistString(value)}`), ].filter((line): line is string => Boolean(line)).join("\n"); - const command = buildServiceShellCommand(bun, cli); + const command = launcher + ? buildServiceLauncherShellCommand(launcher) + : buildServiceShellCommand(bun, cli); return ` @@ -569,6 +592,23 @@ function buildServiceLauncherShellCommand(launcher: string, port = resolveServic return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(launcher)} start --port ${port}`; } +/** + * The exec line the installed launchd plist is expected to carry, derived from the recorded + * install state rather than rediscovered: a launcher install runs the launcher, a legacy or + * stateless install runs the Bun + CLI pair. `start` and `status` compare the live job + * against this, so both must follow the launcher or a healthy launcher-backed job reads as + * "an OLDER plist" (#3464). PATH is deliberately NOT re-walked here. + */ +export function expectedLaunchdCommand( + port: number, + deps: { state?: ServiceInstallState | null; entry?: { bun: string; cli: string } } = {}, +): string { + const state = deps.state === undefined ? readServiceInstallState() : deps.state; + if (state?.launcherPath) return buildServiceLauncherShellCommand(state.launcherPath, port); + const entry = deps.entry ?? cliEntry(); + return buildServiceShellCommand(entry.bun, entry.cli, port); +} + /** * The `--port ` actually baked into the installed launchd plist, or null when it * cannot be read. macOS only — named for launchd rather than "service" so no caller @@ -2251,7 +2291,10 @@ function installLaunchd(): void { // Capture this BEFORE writing: the write below makes the plist exist unconditionally, // so a post-write existsSync would call every fresh install an "installed" service. const wasInstalled = existsSync(p); - writeServiceDefinitionFile(p, buildPlist(), "utf8"); + // Resolve the launcher ONCE and hand the same value to the plist and to install state, + // so the staleness diagnostic judges exactly what launchd runs. + const launcher = stableLauncherEntry(); + writeServiceDefinitionFile(p, buildPlist(resolvedProxyEnv(), { launcher }), "utf8"); // Best-effort: an absent job is fine here, and a failed unload is caught by the // load verification below with a better message than a raw unload error. runLaunchctl(["unload", p]); @@ -2268,7 +2311,7 @@ function installLaunchd(): void { + `then re-run '${wasInstalled ? "ocx service repair" : "ocx service install"}'.`, ); } - writeServiceInstallState(); + writeServiceInstallState("scheduler", launcher); } /** * Deps are named for the layer they replace, not for the process API: `launchctl` @@ -2291,9 +2334,8 @@ export function startLaunchd(deps: { // already be bootstrapped from THIS plist, which is a no-op rather than an error. // `install` can assume a stale job (it just rewrote the plist); `start` cannot, and // throwing here would break `ocx service start` on every healthy service. - const entry = cliEntry(); const live = (deps.matches ?? launchdJobMatchesPlist)( - buildServiceShellCommand(entry.bun, entry.cli), + expectedLaunchdCommand(installedServiceListenPort()), ); if (live.loaded && live.matchesPlist) { console.log("ℹ️ service was already loaded from the current plist; nothing to do."); @@ -4234,14 +4276,11 @@ export async function serviceStatusReport( // Linux/Windows and make the stale-plist case untestable there. const stalePlist = deps.matchesPlist?.() ?? (process.platform === "darwin" ? (() => { - const entry = cliEntry(); // Pass the INSTALLED port explicitly: the default third argument is // resolveServiceListenPort(), which reads OCX_BAKE_PORT/config.port, so after // a config edit the expected string would never match and every run would // print a false "OLDER plist". - return launchdJobMatchesPlist( - buildServiceShellCommand(entry.bun, entry.cli, installedServiceListenPort()), - ); + return launchdJobMatchesPlist(expectedLaunchdCommand(installedServiceListenPort())); })() : null); const staleLine = stalePlist && stalePlist.loaded && !stalePlist.matchesPlist diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index e1068b68ae..e363776e75 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -33,19 +33,21 @@ enumeration twice made a measured 12.3-second fallback cost roughly 25 seconds b - 다른 대안 대신 이 방식을 선택한 이유: Removing or weakening revalidation widens the install race, while a global/TTL cache can outlive startup and stale absence can authorize the wrong home. Exact targeted-result identity lets the ordinary no-task locale fallback coalesce without hiding changed evidence. - 장점, 단점 및 영향: The reported stable zh-CN absence path performs two cheap targeted queries and one full listing. A task that appears is detected by the second targeted query; changed or failed evidence triggers a fresh fail-closed decision, so unusual churn may still pay for two listings rather than guess. -## Linux stable service launcher +## Stable service launcher (launchd and systemd) -Systemd installation resolves the first absolute `ocx` PATH candidate that is both a regular file +Launchd and systemd installation resolve the first absolute `ocx` PATH candidate that is both a regular file and executable, keeps that path lexical so a version-manager shim remains an indirection, and -records the same single resolution in the unit and service state. Unit construction never performs -PATH discovery itself: callers provide either the resolved launcher or an explicit direct Bun/CLI +records the same single resolution in the service definition and service state. Definition +construction (`buildPlist`, `buildUnit`) never performs PATH discovery itself: callers provide either the resolved launcher or an explicit direct Bun/CLI fallback, keeping diagnostics and tests independent of the host PATH. Launcher mode omits the package-local Bun provenance pair because an upgrade may delete that versioned tree. The only runtime path carried through the launcher is a pre-Bun, proof-bound `OPENCODEX_BUN_PATH` whose durable runtime source is `override`; bundled and process fallbacks are rediscovered by the current launcher. The API-auth token remains file-backed and is loaded only by -the service shell at start. +the service shell at start. On macOS, `start` and detailed `status` compare the live launchd job +against `expectedLaunchdCommand`, which follows the recorded `launcherPath` rather than re-walking +PATH, so a launcher-backed job is never misreported as an older plist (#3464). [Decision Log] - 목적과 의도: Keep systemd services upgrade-stable without losing an explicitly trusted Bun override or accepting a non-executable PATH placeholder. @@ -65,6 +67,17 @@ only a typed DNS-resolution failure degrades to proxy resolution; every literal, resolved-address policy error still rejects. Proxy mode logs once that the proxy-selected peer cannot be pinned. Private destinations additionally require allowPrivateNetwork plus NO_PROXY. +Two fake-IP DNS accommodations exist, both for resolved answers only (a literal address in the URL +still rejects). The IANA benchmark range (198.18/15 and its IPv4-mapped IPv6 spellings) is admitted +whenever any outbound proxy applies to the host, because the range itself marks the answer synthetic. +Mihomo's default IPv6 fake-IP range (fdfe:dcba:9876::/48) is ULA and carries no such mark, so it is +admitted only when the proxy variable that matches the URL scheme is set (HTTPS_PROXY for https:, +HTTP_PROXY for http:; ALL_PROXY is not consulted because Bun fetch does not honour it), the host is +not in NO_PROXY, and the request is then bound to that proxy through Bun's explicit `proxy` option +rather than environment inference. Both gates live in the outbound wrapper, not in classification: +`classifyIpv6` and config-time validation (`providerDestinationResolvedError`) never admit the +ULA, so provider save-time checks are unaffected (#3462). + Both paths reject redirects and expose only credential-stripped final-address guidance. This phase does not cover ordinary requests, streaming, retries, or per-hop redirect review on those paths. Caller-owned `provider.fetch` executors are also deferred: they receive literal/config checks and diff --git a/tests/providers/provider-outbound.test.ts b/tests/providers/provider-outbound.test.ts index 5cf7d60039..2853e0e335 100644 --- a/tests/providers/provider-outbound.test.ts +++ b/tests/providers/provider-outbound.test.ts @@ -421,3 +421,100 @@ describe("provider outbound POST transport", () => { expect(calls).toBe(0); }); }); + +describe("#3462 Mihomo IPv6 fake-IP admission is gated on the scheme-matched proxy fetch will use", () => { + type Captured = { allowMihomoIpv6FakeIp?: boolean }; + const ULA = "fdfe:dcba:9876::7e"; + const target = "https://opencode.ai/zen/v1/models"; + + async function run(env: Record, opts: { admit: boolean }) { + for (const key of proxyKeys) delete process.env[key]; + for (const [k, v] of Object.entries(env)) process.env[k] = v; + const originalFetch = globalThis.fetch; + const fetchInits: (RequestInit & { proxy?: string })[] = []; + globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => { + fetchInits.push((init ?? {}) as RequestInit & { proxy?: string }); + return new Response('{"data":[{"id":"muse-spark-1.3-contributor"}]}', { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + try { + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const resolveOptions: Captured[] = []; + const { dependencies, captured } = directDependencies(new Response(null, { status: 500 })); + dependencies.resolveAddresses = mock(async (_url: string, options?: Captured) => { + resolveOptions.push({ allowMihomoIpv6FakeIp: options?.allowMihomoIpv6FakeIp }); + if (!options?.allowMihomoIpv6FakeIp) { + throw new Error(`provider URL hostname opencode.ai resolves to private-network address (${ULA})`); + } + return { hostname: "opencode.ai", addresses: [{ address: ULA, family: 6 }], privateNetwork: false }; + }) as ProviderOutboundDependencies["resolveAddresses"]; + + const attempt = providerOutboundGet("opencode-go", { baseUrl: "https://opencode.ai/zen/v1" }, target, {}, dependencies); + if (opts.admit) { + const response = await attempt; + expect(response.status).toBe(200); + } else { + await expect(attempt).rejects.toThrow(/private-network address/); + } + expect(captured.address).toBeUndefined(); + return { resolveOptions, fetchInits }; + } finally { + globalThis.fetch = originalFetch; + } + } + + test("HTTPS target + HTTPS_PROXY: admitted, and the fetch is bound to that proxy explicitly", async () => { + const { resolveOptions, fetchInits } = await run({ HTTPS_PROXY: "http://127.0.0.1:7897" }, { admit: true }); + expect(resolveOptions).toEqual([{ allowMihomoIpv6FakeIp: true }]); + expect(fetchInits).toHaveLength(1); + expect(fetchInits[0]!.proxy).toBe("http://127.0.0.1:7897"); + expect(fetchInits[0]!.redirect).toBe("manual"); + }); + + test("lowercase https_proxy is honoured the same way", async () => { + const { resolveOptions, fetchInits } = await run({ https_proxy: "http://127.0.0.1:7897" }, { admit: true }); + expect(resolveOptions).toEqual([{ allowMihomoIpv6FakeIp: true }]); + expect(fetchInits[0]!.proxy).toBe("http://127.0.0.1:7897"); + }); + + test("HTTPS target + HTTP_PROXY only: fetch would not use it, so the ULA is not admitted", async () => { + const { resolveOptions, fetchInits } = await run({ HTTP_PROXY: "http://127.0.0.1:7897" }, { admit: false }); + expect(resolveOptions).toEqual([{ allowMihomoIpv6FakeIp: false }]); + expect(fetchInits).toHaveLength(0); + }); + + test("HTTPS target + ALL_PROXY only: not admitted", async () => { + const { resolveOptions, fetchInits } = await run({ ALL_PROXY: "socks5://127.0.0.1:7891" }, { admit: false }); + expect(resolveOptions).toEqual([{ allowMihomoIpv6FakeIp: false }]); + expect(fetchInits).toHaveLength(0); + }); + + test("NO_PROXY match is a direct route: not admitted even with HTTPS_PROXY", async () => { + const { resolveOptions } = await run({ HTTPS_PROXY: "http://127.0.0.1:7897", NO_PROXY: "opencode.ai" }, { admit: false }); + expect(resolveOptions).toEqual([{ allowMihomoIpv6FakeIp: false }]); + }); + + test("without any proxy the branch is byte-identical: no flag, no proxy option", async () => { + const { resolveOptions, fetchInits } = await run({}, { admit: false }); + expect(resolveOptions).toEqual([{ allowMihomoIpv6FakeIp: false }]); + expect(fetchInits).toHaveLength(0); + }); +}); + +describe("effectiveProxyFor picks the variable Bun fetch actually honours", () => { + test("scheme-matched selection; ALL_PROXY is never consulted", async () => { + const { effectiveProxyFor } = await import("../../src/lib/proxy-env"); + const https = new URL("https://opencode.ai/zen/v1/models"); + const http = new URL("http://ollama.lan:11434/v1/models"); + expect(effectiveProxyFor(https, { HTTPS_PROXY: "http://p:1" })).toBe("http://p:1"); + expect(effectiveProxyFor(https, { https_proxy: " http://p:2 " })).toBe("http://p:2"); + expect(effectiveProxyFor(https, { HTTP_PROXY: "http://p:3" })).toBeNull(); + expect(effectiveProxyFor(https, { ALL_PROXY: "http://p:4" })).toBeNull(); + expect(effectiveProxyFor(http, { HTTP_PROXY: "http://p:5" })).toBe("http://p:5"); + expect(effectiveProxyFor(http, { HTTPS_PROXY: "http://p:6" })).toBeNull(); + expect(effectiveProxyFor(https, { HTTPS_PROXY: " " })).toBeNull(); + expect(effectiveProxyFor(new URL("ftp://x/"), { HTTPS_PROXY: "http://p:7", HTTP_PROXY: "http://p:7" })).toBeNull(); + }); +}); diff --git a/tests/routing/destination-policy-resolved.test.ts b/tests/routing/destination-policy-resolved.test.ts index 7b471bd793..39eac5331c 100644 --- a/tests/routing/destination-policy-resolved.test.ts +++ b/tests/routing/destination-policy-resolved.test.ts @@ -382,3 +382,76 @@ describe("#2810 explicit-zero mapped benchmark answers under the fake-IP opt-in" } }); }); + +describe("#3462 Mihomo IPv6 fake-IP answers (fdfe:dcba:9876::/48) under the dedicated opt-in", () => { + const OPT_IN = { context: "provider URL", allowMihomoIpv6FakeIp: true } as const; + const URL_ = "https://opencode.ai/zen/v1/models"; + + test("the reported answer is accepted and stays non-private", async () => { + lookupMock.mockResolvedValueOnce([{ address: "fdfe:dcba:9876::7e", family: 6 }]); + const resolved = await resolvePublicAddresses(URL_, OPT_IN); + expect(resolved.privateNetwork).toBe(false); + expect(resolved.addresses).toEqual([{ address: "fdfe:dcba:9876::7e", family: 6 }]); + }); + + test("compressed, uppercase, expanded and non-zero-fourth-hextet spellings all match the /48", async () => { + for (const address of [ + "FDFE:DCBA:9876::1", + "fdfe:dcba:9876:0:0:0:0:1", + "fdfe:dcba:9876:ffff::1", + "fdfe:dcba:9876:1:2:3:4:5", + ]) { + lookupMock.mockResolvedValueOnce([{ address, family: 6 }]); + const resolved = await resolvePublicAddresses(URL_, OPT_IN); + expect(resolved.privateNetwork).toBe(false); + } + }); + + test("rejects without the opt-in, and the benchmark opt-in alone does not admit it", async () => { + lookupMock.mockResolvedValueOnce([{ address: "fdfe:dcba:9876::7e", family: 6 }]); + await expect(resolvePublicAddresses(URL_, { context: "provider URL" })) + .rejects.toThrow("private-network address (fdfe:dcba:9876::7e)"); + + lookupMock.mockResolvedValueOnce([{ address: "fdfe:dcba:9876::7e", family: 6 }]); + await expect(resolvePublicAddresses(URL_, { context: "provider URL", allowBenchmarkAddresses: true })) + .rejects.toThrow("private-network address (fdfe:dcba:9876::7e)"); + }); + + test("a literal ULA URL still rejects even with the opt-in (DNS answers only)", async () => { + await expect(resolvePublicAddresses("https://[fdfe:dcba:9876::7e]/v1/models", OPT_IN)) + .rejects.toThrow("private-network address"); + }); + + test("adjacent prefixes, ordinary ULA, loopback, metadata and RFC1918 stay rejected", async () => { + for (const [address, family, detail] of [ + ["fdfe:dcba:9877::1", 6, "private-network address"], + ["fdfe:dcba:9875::1", 6, "private-network address"], + ["fdfd:dcba:9876::1", 6, "private-network address"], + ["fd00::1", 6, "private-network address"], + ["::1", 6, "loopback address"], + ["169.254.169.254", 4, "metadata"], + ["10.0.0.5", 4, "private-network address"], + ] as const) { + lookupMock.mockResolvedValueOnce([{ address, family }]); + await expect(resolvePublicAddresses(URL_, OPT_IN)).rejects.toThrow(detail); + } + }); + + test("a fake-IP answer mixed with a real private answer still rejects", async () => { + lookupMock.mockResolvedValueOnce([ + { address: "fdfe:dcba:9876::7e", family: 6 }, + { address: "10.0.0.5", family: 4 }, + ]); + await expect(resolvePublicAddresses(URL_, OPT_IN)).rejects.toThrow("private-network address (10.0.0.5)"); + }); + + test("config-time validation is unchanged: the canonical benchmark opt-in never admits the ULA", async () => { + lookupMock.mockResolvedValueOnce([{ address: "fdfe:dcba:9876::7e", family: 6 }]); + const error = await providerDestinationResolvedError( + "openai", + provider("https://chatgpt.com/backend-api/codex"), + { allowBenchmarkAddresses: true }, + ); + expect(error).toContain("private-network address (fdfe:dcba:9876::7e)"); + }); +}); diff --git a/tests/service/service.test.ts b/tests/service/service.test.ts index 8c9d10cb8a..e36a2785b6 100644 --- a/tests/service/service.test.ts +++ b/tests/service/service.test.ts @@ -7,7 +7,7 @@ import { pathToFileURL } from "node:url"; import * as serviceModule from "../../src/service"; import { saveConfig } from "../../src/config"; import { windowsEnvIndirectBatchValue } from "../../src/lib/win-paths"; -import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml as buildWindowsTaskXmlProduction, buildWindowsTaskXmlDocument, deriveWindowsServiceDiagnostic, deriveWindowsServiceDiagnosticForCurrentUser, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceArgs, parseServiceInstallState, planServiceCommand, prepareServiceInstall, probeServiceInstallation, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, reportServiceServing, resolveServiceListenPort, runLaunchctl, selectServiceSubcommand, SERVICE_INSTALL_HEALTH_MS, SERVICE_INSTALL_HEALTH_WINDOWS_MS, serviceInstallHealthMs, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, stableLauncherEntry, systemdNeedsDaemonReload, systemdServiceInstallCleanupOps, uninstallSystemd, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy as windowsTaskRegistrationHealthyProduction } from "../../src/service"; +import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml as buildWindowsTaskXmlProduction, buildWindowsTaskXmlDocument, deriveWindowsServiceDiagnostic, deriveWindowsServiceDiagnosticForCurrentUser, expectedLaunchdCommand, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceArgs, parseServiceInstallState, planServiceCommand, prepareServiceInstall, probeServiceInstallation, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, reportServiceServing, resolveServiceListenPort, runLaunchctl, selectServiceSubcommand, SERVICE_INSTALL_HEALTH_MS, SERVICE_INSTALL_HEALTH_WINDOWS_MS, serviceInstallHealthMs, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, stableLauncherEntry, systemdNeedsDaemonReload, systemdServiceInstallCleanupOps, uninstallSystemd, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy as windowsTaskRegistrationHealthyProduction } from "../../src/service"; import type { ServiceDiagnostic } from "../../src/service"; import { definitionCarriesCredential, resolvedProxyEnv, writeServiceDefinitionFile } from "../../src/service"; import { buildWinswXml } from "../../src/lib/winsw"; @@ -1182,6 +1182,68 @@ describe("launchd service plist", () => { expect(direct).toContain("OCX_BUN_RUNTIME_PATH"); }); + + // #3464. The macOS counterpart of the systemd launcher test above: a mise/asdf upgrade replaces + // the versioned package directory, and a plist that named the old Bun + CLI pair keeps launchd + // on the stale build until someone restarts it. Naming the shim lets the next start follow it. + test("a stable launcher install names the launcher in the plist and bakes no versioned path (#3464)", () => { + const launcher = "/home/u/.local/share/mise/shims/ocx"; + const plist = buildPlist(resolvedProxyEnv({}), { + launcher, + runtime: { path: "/opt/opencodex/versioned/bun", source: "bundled", overrideEnv: "OPENCODEX_BUN_PATH" }, + }); + + expect(plist).toContain(launcher); + expect(plist).toContain("start --port"); + for (const forbidden of [ + "OCX_BUN_RUNTIME_PATH", + "OCX_BUN_RUNTIME_SOURCE", + "OPENCODEX_BUN_PATH", + "/opt/opencodex/versioned/bun", + "cli/index.ts", + ]) expect(plist).not.toContain(forbidden); + // The token still comes from the file at start, never from the plist. + expectTextToContainPath(plist, serviceApiTokenFilePath()); + expect(plist).toContain("OPENCODEX_API_AUTH_TOKEN"); + // launchdListenPort reads the same "start --port N" tail from either command shape. + expect(launchdListenPort({ readPlist: () => plist })).toBe(resolveServiceListenPort()); + + // Without a launcher the plist keeps the previous shape, so source checkouts are unaffected. + const direct = buildPlist(resolvedProxyEnv({}), { launcher: null }); + expectTextToContainPath(direct, join("cli", "index.ts")); + expect(direct).toContain("OCX_BUN_RUNTIME_PATH"); + expect(direct).toContain("OCX_BUN_RUNTIME_SOURCE"); + }); + + test("launcher mode preserves only a proof-bound Bun override, never an ambient one (#3464)", () => { + const launcher = "/home/u/.local/share/mise/shims/ocx"; + const trusted = buildPlist(resolvedProxyEnv({}), { + launcher, + runtime: { path: "/custom/bun", source: "override", overrideEnv: "OPENCODEX_BUN_PATH" }, + }); + expect(trusted).toContain("OPENCODEX_BUN_PATH/custom/bun"); + expect(trusted).not.toContain("OCX_BUN_RUNTIME_PATH"); + + const bundled = buildPlist(resolvedProxyEnv({}), { + launcher, + runtime: { path: "/custom/bun", source: "bundled", overrideEnv: "OPENCODEX_BUN_PATH" }, + }); + expect(bundled).not.toContain("OPENCODEX_BUN_PATH"); + expect(bundled).not.toContain("/custom/bun"); + }); + + test("launcher paths with shell and XML metacharacters stay quoted in the plist (#3464)", () => { + const launcher = "/home/u/My Tools & Shims/it's/ocx"; + const plist = buildPlist(resolvedProxyEnv({}), { + launcher, + runtime: { path: "/opt/bun", source: "bundled", overrideEnv: "OPENCODEX_BUN_PATH" }, + }); + // XML-escaped ampersand inside the ProgramArguments string; the shell quoting survives. + expect(plist).toContain("&"); + expect(plist).not.toContain("Shims/it's/ocx start"); + expect(launchdListenPort({ readPlist: () => plist })).toBe(resolveServiceListenPort()); + }); + // The scenario itself, executed rather than asserted: retarget the shim the way an upgrade // does, delete the old version, and check the generated command still reaches live code. test("the generated launcher command follows a retargeted shim after the old version is gone", () => { @@ -3143,6 +3205,38 @@ describe("launchctl load verification", () => { }); }); + + // #3464. start and status compare the live job against the command the plist SHOULD carry. + // A launcher install carries the launcher line, so the comparison must follow the recorded + // install state or every healthy launcher-backed service reads as "an OLDER plist". + describe("expectedLaunchdCommand follows the recorded launcher", () => { + const entry = { bun: "/opt/opencodex/versioned/bun", cli: "/opt/opencodex/versioned/src/cli/index.ts" }; + const base = { version: 2 as const, codexHome: "/h/.codex", opencodexHome: "/h/.opencodex", backend: "scheduler" as const }; + + test("a recorded launcher yields the launcher exec line at the installed port", () => { + const command = expectedLaunchdCommand(14001, { + state: { ...base, bunPath: entry.bun, cliPath: entry.cli, launcherPath: "/home/u/.local/share/mise/shims/ocx" }, + entry, + }); + expect(command).toContain("exec '/home/u/.local/share/mise/shims/ocx' start --port 14001"); + expect(command).not.toContain(entry.cli); + }); + + test("v1 / legacy state without a launcher yields the Bun + CLI pair", () => { + const command = expectedLaunchdCommand(14001, { + state: { version: 1, codexHome: "/h/.codex", opencodexHome: "/h/.opencodex", bunPath: entry.bun, cliPath: entry.cli }, + entry, + }); + expect(command).toContain(`exec '${entry.bun}' '${entry.cli}' start --port 14001`); + }); + + test("missing state falls back to the Bun + CLI pair and never re-walks PATH", () => { + const command = expectedLaunchdCommand(14001, { state: null, entry }); + expect(command).toContain(`exec '${entry.bun}' '${entry.cli}' start --port 14001`); + expect(command).not.toContain("shims/ocx"); + }); + }); + describe("startLaunchd", () => { // A runLaunchctl RESULT, not a spawnSync result. const failedLoad = () => ({ ok: true, stdout: "", stderr: "Load failed: 5: Input/output error" });