diff --git a/devlog/_plan/260905_bug_triage_stack/000_research.md b/devlog/_plan/260905_bug_triage_stack/000_research.md new file mode 100644 index 0000000000..12be7c5c78 --- /dev/null +++ b/devlog/_plan/260905_bug_triage_stack/000_research.md @@ -0,0 +1,69 @@ +# 000 — Live bug manifest and disposition research + +Snapshot: 2026-09-04T22:28:45Z. Worktree `/Users/jun/.codex/worktrees/ef41/opencodex`, +HEAD `6d9639165` (== `origin/dev` at snapshot; `origin/dev` advanced to `980a9fbed` +(#3537, Astra pricing) during the evidence pass). `git diff 6d9639165 980a9fbed` touches +`src/providers/registry.ts`, `src/codex/catalog/native-models.ts`, provider-fetch and +providers docs — line numbers below are qualified to `6d9639165`; the *behavior* the +anchors prove is unchanged on `980a9fbed` (auditor re-verified: registry mapping at +dev:1629, Daybreak-only gate at dev:47). Implementation cycles branch from fresh +`origin/dev` and re-anchor at their own P. + +Session `01a06e87-0804-7ed1-a317-a724b8ee1c35`, goalplan +`opencodex-open-bug-triage-and-stacked-pr-fix-tra`. Eleven parallel read-only evidence +lanes (subagents) produced the findings; every verdict names its `path:line` anchors. +Raw lane outputs are in scratch (`.tmp/triage/lanes/`), not tracked. + +## Constraints carried from the user + +- No repository-wide local suite (`bun run test` / bare `bun test`). Focused files, + `bun run typecheck`, `bun run test:changed` only. +- Push with `git push --no-verify` (pre-push hook runs the forbidden suite). +- Hosted CI is trailing: push first, watch exact-head `gh pr checks`, fix forward. +- Fixes land as a dependency-ordered stacked PR chain (first base `dev`, each next base + the previous head branch). No merges authorized. +- Issues are closed only with a concrete `dev` commit/PR reference. + +## Open bug-labelled issues (live scan: exactly 12, no extras) + +| Issue | Title (short) | Verdict | Basis | +|---|---|---|---| +| #3424 | opencode-go muse-spark-1.3-contributor 500 | **ALREADY_FIXED_ON_DEV** | #3317 `878f75417` added `"muse-spark-1.3-contributor": "openai-responses"` to `modelWireDefaults` (`src/providers/registry.ts:1627`); pinned by `tests/providers/muse-spark-web-search-compat.test.ts:117`. | +| #3352 | Plus account GPT-5.6 401 via proxy | **ALREADY_FIXED_ON_DEV** | #3460 `f3d0edb34`: `ACCOUNT_GATED_NATIVE_OPENAI_MODELS` now holds only Daybreak (`src/codex/catalog/native-models.ts:55`); gate at `src/codex/auth-context.ts:408/435` no longer applies to Sol/Terra/Luna; pinned by `tests/codex-integration/native-model-toggle.test.ts:120` and `codex-model-entitlements.test.ts:458`. Last reporter comment (05:52Z) predates the merge (11:16Z). `plan:null` is a separate WHAM-usage symptom (`src/codex/auth-api.ts:888-943`), not roster evidence. | +| #3467 | Google "User location is not supported" → invalid_request | **CARRY PR #3469** → 010 | `src/adapters/google-errors.ts:74` still returns `invalid request` for any 400; `src/lib/errors.ts:273/286` maps to `invalid_request_error`. PR preimage blobs equal HEAD for all runtime files; conflict is only the tests/ layout move (#3513/#3518). | +| #3462 | Discovery blocked under Mihomo IPv6 fake-ip | **FIXABLE** → 020 | `fdfe:dcba:9876::/48` classifies as `private-network address` (`src/lib/destination-policy.ts:191`) so `isBenchmarkDnsAnswer` (`:138`) never admits it. PR #3489 touches only the no-proxy TUN gate, not this range. | +| #3464 | mise upgrade leaves launchd proxy stale | **FIXABLE (partial)** → 030 | `buildPlist` (`src/service.ts:490-515`) bakes `cliEntry()` Bun+CLI paths; systemd already uses `stableLauncherEntry()` (`:3301`). #2909 explicitly left launchd untouched. Launcher parity fixes *which version starts next*; it cannot replace an already-running proxy, so the PR is `Refs #3464` and the issue stays open for the running-mismatch half (audit blocker 2). | +| #3522 | Windows spill failures behind healthy readiness | **CARRY PR #3525** → 040 | `src/responses/state.ts:383` discards the exception, `responseStateMetrics()` (`:2151`) is 12 numerics; seven of eight PR paths byte-identical to dev, the eighth is a tests/ move. Observability fix only; issue stays open per maintainer plan. | +| #3406 | Dashboard Codex disable dialog misleading | **CARRY PR #3407** → 050 | `IntegrationsOverview.tsx:691` picks Grok copy; `overview-clients.ts:191` `togglePath: null`; `native-integration-routes.ts:686` passes `getConfigPath()`. Base-to-current drift: `8b30d60b3` (collapsed-client revert) and `413227888` (#3477 journal dialog). | +| #3425 | Keeps routing to 5h-exhausted account after 502s | **NEEDS_INFO** | Plain 502 is `transient` (`src/codex/routing.ts:447`; `:445` is the 429/402 quota branch); failure failover threshold is independent config (`:2459`); no forced WHAM refresh after 502 (`src/codex/quota.ts:421`), stale snapshot can persist. Aggregate log cannot distinguish stale-snapshot / disabled failover / fixed-account / interleaved success. Ask listed in lane output. | +| #3320 | Windows non-ASCII scheduler task misclassified | **NEEDS_INFO** | Decoding fixed by #3438 `c85c48249` (`src/lib/windows-user-principal.ts:150/190`, `tests/windows/windows-user-principal-nonascii.test.ts:69`); SID-form `` is accepted (`src/service.ts:1909/2136`). Reporter's XML was post-patch; pre-repair evidence needed. | +| #3245 | macOS Codex 0.152 stream disconnect | **NEEDS_INFO / UPSTREAM** | 426 deliberate (`src/server/index.ts:1144`), POST fallback covered (`tests/server/server-auth.test.ts:1384`); probe showed no POST reached the proxy. | +| #3506 | Cursor/Grok 4.6 no-progress loop | **UPSTREAM** | Maintainer keeps as tracker; liveness only measures protocol frames (`src/adapters/cursor/live-transport.ts:863`). | +| #3433 | Hermes zero cache hits | **PRODUCT_DECISION** | `session_id` synthesis needs positive per-session provenance (`src/claude/inbound.ts:543`, `src/server/claude-messages.ts:799`); reporter's client-side split-affinity workaround already works with existing forwarding. | + +## Actions derived + +- Close with evidence comment: #3424, #3352. +- Stacked fix chain (dependency-ordered by blast radius, smallest first so lower PRs + can land independently): 010 → 020 → 030 → 040 → 050. See `005_stack_order.md`. +- Leave open, no action this cycle: #3425, #3320, #3245, #3506, #3433. + +## Attribution (verified via `gh pr view --json commits`) + +- #3469 → `Co-authored-by: agentHits ` +- #3525 → `Co-authored-by: Ingwannu ` +- #3407 → `Co-authored-by: turin ` +- #3489 is not carried (does not fix #3462); no trailer needed for 020. + + +## Audit round 1 (A phase, astra reviewer) — GO-WITH-FIXES, 4 blockers, all folded + +1. 010 precedence: location detection must sit *after* the auth (`UNAUTHENTICATED`) and + rate-limit branches in `classifyGoogle`, with enum-driven adapter→envelope tests. + Folded into 010 §Amendments (top of doc). +2. 030 `Closes` → `Refs` (running-process mismatch not addressed). Folded here, 005, 030. +3. Stack fix-forward must propagate to children (rebase descendants, re-verify base + ancestry, fresh exact-head checks). Folded into 005 step 5. +4. Baseline/anchor inaccuracies (`routing.ts:445`→`:447`, "no files differ", + 040 "HEAD == origin/dev"). Corrected in place. + diff --git a/devlog/_plan/260905_bug_triage_stack/005_stack_order.md b/devlog/_plan/260905_bug_triage_stack/005_stack_order.md new file mode 100644 index 0000000000..10b4d00c98 --- /dev/null +++ b/devlog/_plan/260905_bug_triage_stack/005_stack_order.md @@ -0,0 +1,35 @@ +# 005 — Stack order and PR contract + +All five fixes are mutually independent in code. The stack order is chosen so each +lower PR is reviewable and mergeable alone and each higher PR carries the smallest +possible diff relative to its base. + +| Pos | Doc | Branch | Base | Closes | Author trailer | +|---|---|---|---|---|---| +| 1 | 010 | `codex/3467-google-location-error` | `dev` | #3467 | agentHits | +| 2 | 020 | `codex/3462-mihomo-ipv6-fakeip` | pos 1 head | #3462 | — | +| 3 | 030 | `codex/3464-launchd-stable-launcher` | pos 2 head | Refs #3464 (does not close; running-process mismatch remains) | — | +| 4 | 040 | `codex/3522-spill-write-health` | pos 3 head | (references #3522, does not close) | Ingwannu | +| 5 | 050 | `codex/3406-codex-toggle-truth` | pos 4 head | #3406 | turin | + +Per-PR contract (each PR): + +1. Branch from the previous position's head (pos 1 from fresh `origin/dev`). +2. Implement per its decade doc; focused tests + `bun run typecheck` only. +3. Commit with trailer where applicable; `git push --no-verify -u origin `. +4. `gh pr create --base --draft=false` with the full template (Summary / + Verification / Checklist), `Closes #N`, and for 050 a GUI screenshot. +5. Record `headRefOid` + `gh pr checks` run ids in `060_ledger.md`; fix forward on red. +6. After a lower PR merges, restack the next one onto `dev` (retarget base) — out of + scope for this loop (no merges authorized). + +Existing contributor PRs superseded by carries: #3469, #3525, #3407. Leave them open; +the maintainer decides whether to close them after the carry lands. + + +Child propagation (audit blocker 3): any commit added to a lower position after higher +branches exist must be propagated upward — `git rebase ` each descendant in +order, `git push --no-verify --force-with-lease`, then re-verify +`git merge-base --is-ancestor ` and re-record fresh exact-head +`gh pr checks` for every child. A child's earlier green does not survive a parent change. + diff --git a/devlog/_plan/260905_bug_triage_stack/010_wp2_google_location_error.md b/devlog/_plan/260905_bug_triage_stack/010_wp2_google_location_error.md new file mode 100644 index 0000000000..c4a0a5c011 --- /dev/null +++ b/devlog/_plan/260905_bug_triage_stack/010_wp2_google_location_error.md @@ -0,0 +1,225 @@ +# 010 — Google location-not-supported classification (#3467) + +Work-phase: one full PABCD cycle. Closes #3467. Carries PR #3469 (supersedes; Co-authored-by: agentHits ). +Source evidence lane output is reproduced below verbatim (diff-level). Stale-check +against the current tree at this cycle's P before implementing. + +--- + +## Amendments from audit round 1 (binding over the lane text below) + +- **Precedence (blocker 1):** in `classifyGoogle` insert the location branch *after* the + existing auth (`status === 401 || enumStatus === "UNAUTHENTICATED"`), permission + (403/`PERMISSION_DENIED`), quota and rate-limit (429/`RESOURCE_EXHAUSTED`) branches, and + *before* the generic 400/`INVALID_ARGUMENT` fallthrough — not at line 62 as the lane + text proposes. Same rule in `src/lib/errors.ts`: keep authentication / rate-limit + classification ahead of `isLocationUnsupportedMessage`. +- **Tests:** add enum-driven cases: `{status:400, enumStatus:"UNAUTHENTICATED", message: + "...location is not supported..."}` → `authentication failed` (adapter) → + `authentication_error` (envelope); `RESOURCE_EXHAUSTED` + location wording → quota/rate + limit; plain `FAILED_PRECONDITION` + location wording → `location not supported` → + `permission_error`/`location_not_supported`. + +--- + +## 1) VERDICT: CARRY_EXISTING_PR + +Not fixed on the checked checkout or inspected `origin/dev`. + +- HEAD: `6d9639165581546cdcebe96bc911446caabdd7d0` +- `origin/dev` advanced to `980a9fbede123f411f52c8b061a05fb995ae159d`; all six relevant source/test files are unchanged between those refs. +- [PR #3469](https://github.com/lidge-jun/opencodex/pull/3469): OPEN, APPROVED, **CONFLICTING / DIRTY**, head `e11089af85f8c1da4e67fe388b768d09078e8dcb`. + +## 2) EVIDENCE + +Current-dev anchors: + +- [src/adapters/google-errors.ts:74](/Users/jun/.codex/worktrees/ef41/opencodex/src/adapters/google-errors.ts:74): `if (status === 400 || …)` immediately returns `` `${label} invalid request` ``. No location-specific branch. +- [src/adapters/google-errors.ts:87](/Users/jun/.codex/worktrees/ef41/opencodex/src/adapters/google-errors.ts:87): `classifyGoogle(label, status, enumStatus, …)` receives the upstream message/status. +- [src/lib/errors.ts:273](/Users/jun/.codex/worktrees/ef41/opencodex/src/lib/errors.ts:273): `text.includes("invalid request")` maps to `invalid_request_error`; line 286 independently does the same for HTTP 400. +- [src/bridge.ts:2100](/Users/jun/.codex/worktrees/ef41/opencodex/src/bridge.ts:2100): `const error = classifyError(status, type, message);` establishes the public-envelope consumer. +- [tests/adapters/google/google-errors.test.ts:79](/Users/jun/.codex/worktrees/ef41/opencodex/tests/adapters/google/google-errors.test.ts:79): final existing case is `"non-RESOURCE_EXHAUSTED status is not quota exhaustion even with quota keyword"`; no location regression. + +`git log origin/dev -- src/adapters/google-errors.ts` identifies the latest change as `bfe2cb5a1` / **#2573**, a quota-classification fix, not this issue. + +**Conflict evidence:** all runtime preimage blobs in `gh pr diff 3469` exactly match HEAD: + +| File | PR preimage = current blob | +|---|---| +| `src/adapters/google-errors.ts` | `d78ee1fb9b…` | +| `src/adapters/google-http.ts` | `f7b90de87e…` | +| `src/lib/errors.ts` | `624917507c…` | + +The three old root-level tests moved. Comparing their PR preimages against current files shows **only import-depth changes**, including dynamic imports. Moves landed through `b20af6668` / **#3513** and `79e03643d` / **#3518**. Preserve current paths/imports; transplant test additions, not whole files. + +## 3) DIFF-LEVEL CARRY PLAN + +All changes below are **MODIFY**; no new files. + +### Runtime: minimal functional carry + +**`src/lib/errors.ts`**, after `isPermissionMessage`: + +```diff ++const LOCATION_UNSUPPORTED_PATTERNS = [ ++ "location is not supported", ++ "location not supported", ++ "unsupported location", ++ "region is not supported", ++ "unsupported region", ++ "country is not supported", ++ "not supported in your country", ++ "not supported in your region", ++] as const; ++ ++export function isLocationUnsupportedMessage(text: string): boolean { ++ const lower = text.toLowerCase(); ++ return LOCATION_UNSUPPORTED_PATTERNS.some(needle => lower.includes(needle)); ++} +``` + +At current line 254, **after authentication/subscription handling**, before generic permission: + +```diff ++ if (type === "location_not_supported" || isLocationUnsupportedMessage(text)) { ++ return { message, type: "permission_error", code: "location_not_supported" }; ++ } + if ( + status === 403 || +``` + +**`src/adapters/google-errors.ts`**: + +```diff ++import { isLocationUnsupportedMessage } from "../lib/errors"; +``` + +At current line 62, preserving the PR’s insertion point: + +```diff + if ((!enumStatus || enumStatus === "RESOURCE_EXHAUSTED") && quotaExhausted) return `${label} quota exhausted`; ++ if (isLocationUnsupportedMessage(lower)) return `${label} location not supported`; + if (status === 429 || enumStatus === "RESOURCE_EXHAUSTED" || lower.includes("rate limit")) { +``` + +**Deliberate reductions from #3469:** omit unused pattern/alias re-exports and leave `src/adapters/google-http.ts` unchanged. Its existing formatter already uses the changed classifier at lines 22–26. The VPN/TUN diagnostic warning is unnecessary to fix classification and makes an unproven remediation claim. + +Do **not** classify every `FAILED_PRECONDITION` as geographic denial. + +### Regression tests + +**`tests/adapters/google/google-errors.test.ts`** — carry PR phrase cases for Antigravity and Vertex; preserve `../../../src/…` imports. Add negative generic-precondition coverage: + +```ts +const body = JSON.stringify({ + error: { + code: 400, + status: "FAILED_PRECONDITION", + message: "User location is not supported for the API use.", + }, +}); +expect(safeAntigravityHttpErrorMessage(400, body)) + .toBe("Antigravity location not supported: User location is not supported for the API use."); +expect(safeVertexHttpErrorMessage(400, body)) + .toContain("Vertex AI location not supported"); +``` + +Also assert unrelated `"Precondition check failed"` remains `invalid request`. + +**`tests/server/error-fidelity.test.ts`** — carry the PR’s raw-message, normalized-message, explicit-type, mixed-case, and negative matcher assertions. Keep `../../src/lib/errors`. Add public-envelope coverage: + +```ts +const response = formatErrorResponse( + 400, "upstream_error", + "Antigravity location not supported: User location is not supported for the API use.", +); +expect(response.status).toBe(400); +expect((await response.json()).error).toMatchObject({ + type: "permission_error", + code: "location_not_supported", +}); +``` + +Add precedence cases: HTTP 401 remains authentication; HTTP 429 remains rate-limit despite location wording. + +**`tests/adapters/google/google-vertex-http.test.ts`** — replace the proposed warning tests with behavior coverage: + +```ts +const mock = mockFetch([new Response( + vertexError(400, "FAILED_PRECONDITION", + "User location is not supported for the API use."), + { status: 400 }, +)]); +const res = await fetchAntigravityWithRetry(request, { timeoutMs: 5_000 }); +expect(res.status).toBe(400); +expect(await res.text()).toContain("Antigravity location not supported"); +expect(mock.calls).toHaveLength(1); +``` + +Keep existing redaction and raw-pass-through tests. + +Layout is already registered: + +- `scripts/test-layout/layout.json:596,632,640` +- `tests/fixtures/test-layout-expected.json:433,469,477` + +No registry edits needed when extending these files. Any new test filename would require **both** registries. + +Focused commands for implementation: + +```bash +bun test tests/adapters/google/google-errors.test.ts +bun test tests/adapters/google/google-vertex-http.test.ts +bun test tests/server/error-fidelity.test.ts +``` + +### Docs, attribution, risk + +- **MODIFY `docs-site/src/content/docs/reference/adapters.md`**, Google section at line 159: document location denial → `permission_error` / `location_not_supported`, unchanged transport status, and that this does not remove Google’s restriction. Existing translated adapter sections contain no contradictory location contract. +- Required trailer, verified from both contributor commits via `gh pr view --json commits`: + +```text +Co-authored-by: agentHits +``` + +- Risk: shared error taxonomy affects providers beyond Antigravity. Preserve auth/quota precedence, redaction, raw-mode behavior, and transport status. +- No credential storage, OAuth flow, credential destination, or access-control change. Nevertheless, explicitly review the shared authentication/permission classification boundary under [MAINTAINERS.md:60](/Users/jun/.codex/worktrees/ef41/opencodex/MAINTAINERS.md:60). + +## 4) OPEN QUESTIONS / RESIDUAL UNCERTAINTY + +- No implementation or tests executed; this is source-backed triage, not merge readiness. +- All six PR files reviewed. The warning and its warning-specific assertions are deliberately excluded from the minimal carry. +- Parent must refresh the integration SHA before implementation; `origin/dev` moved during triage. No Git writes, GitHub comments, or file edits performed. + + + +## wp2 P stale-check (2026-09-04T22:50Z, origin/dev 980a9fbed) + +- `src/adapters/google-errors.ts` `classifyGoogle` and `src/lib/errors.ts` `classifyError` + unchanged vs the lane snapshot (verified by re-reading lines 58-79 / 240-290). +- PR #3469's own diff places the location branch *directly after* the quota-exhausted check, + i.e. before rate-limit and auth — exactly the precedence defect audit blocker 1 named. + The carry therefore moves the branch to after the 403/access-denied branch (before 503), + and in `classifyError` keeps it after the auth block but *before* the subscription / + permission blocks so `location_not_supported` wins over generic `permission_denied`. +- Drop the `google-http.ts` console.warn (unproven VPN/TUN advice) and the alias re-exports. +- Parallel-task note: a separate maintainer session opened carry PRs #3539-#3546 against + `dev` while this loop ran; **#3542 is a carry of #3525** (this unit's 040 / wp5). wp5 + will therefore not open a duplicate PR — its cycle re-verifies #3542's exact head and + stacks 050 on top of it (see 040 amendment at wp5's P). No carry exists yet for #3469 + or #3407. + + +## wp2 audit round 1 — GO-WITH-FIXES (1 blocker, folded) + +Blocker: message-only adapter paths (`inferHttpStatusFromAdapterMessage`, `src/lib/errors.ts:~360`) +would infer **502** for the new `… location not supported: …` message (no `invalid`/`unsupported` +cue), while classified envelopes infer **403** via `permission_error`. + +Decision: a location denial is a permission-class rejection. `inferHttpStatusFromAdapterMessage` +returns **403** when `isLocationUnsupportedMessage(lower)` — placed directly after the +authentication check so 401 still wins — making message-only and envelope paths agree. +Docs wording: "direct upstream HTTP status is preserved (400); message-only/terminal paths +classify it as 403 permission." Tests: `adapterFailureFromMessage` + `httpStatusFromError` +cases added to error-fidelity. + diff --git a/devlog/_plan/260905_bug_triage_stack/020_wp3_mihomo_ipv6_fakeip.md b/devlog/_plan/260905_bug_triage_stack/020_wp3_mihomo_ipv6_fakeip.md new file mode 100644 index 0000000000..f855285f5f --- /dev/null +++ b/devlog/_plan/260905_bug_triage_stack/020_wp3_mihomo_ipv6_fakeip.md @@ -0,0 +1,159 @@ +# 020 — Mihomo IPv6 fake-ip discovery exception (#3462) + +Work-phase: one full PABCD cycle. Closes #3462. +Source evidence lane output is reproduced below verbatim (diff-level). Stale-check +against the current tree at this cycle's P before implementing. + +--- + +## wp3 plan amendments (audit rounds 1–2; binding over the lane text below) + +Implementation shape (see the PR diff for the authoritative change): + +1. `src/lib/proxy-env.ts`: `effectiveProxyFor(url, env)` selects the proxy variable that + matches the URL scheme (`HTTPS_PROXY` for `https:`, `HTTP_PROXY` for `http:`); returns null + when none is set. `ALL_PROXY` is not consulted. +2. `src/lib/provider-outbound.ts`: `allowMihomoIpv6FakeIp` is derived from that selection plus + `!noProxyMatches(parsed)`, and when the flag admitted the answer the request is issued with + an explicit `proxy:` option bound to the same selection (Bun `BunFetchRequestInit.proxy`, + bun-types 1.4.0 `globals.d.ts:1944`). Snapshot both before the DNS await; no direct retry + after a proxy failure. All other branches are unchanged. +3. `src/lib/destination-policy.ts`: separate `allowMihomoIpv6FakeIp` option (not a relabel of + the `benchmark address` detail), so config-time validation in `provider-routes.ts` is untouched. +4. Tests: `tests/providers/provider-outbound.test.ts` (scheme-matched proxy selection matrix, + captured `proxy` option, NO_PROXY) and `tests/routing/destination-policy-resolved.test.ts` + (accept under flag / reject without / literal / adjacent prefix / benchmark-only flag / + config-validation path). +5. Docs: English `providers.md` §"Provider diagnostic outbound safety" (:333) and the seven + locale pages (ko:129 ja:129 zh-cn:129 zh-tw:103 fr:143 ru:145 tr:151). + +Review notes and the reviewer's transport measurements live in scratch (`.tmp/triage/`), per +AGENTS.md §Security working notes; they are summarized publicly once the PR is merged. + +--- + +1) VERDICT: FIXABLE + +Neither dev nor [PR #3489](https://github.com/lidge-jun/opencodex/pull/3489) fixes #3462. Implement a separate, proxy-only IPv6 fake-IP exception; do not carry #3489 as this issue’s fix. + +2) EVIDENCE + +Snapshot: checkout HEAD is `6d9639165581546cdcebe96bc911446caabdd7d0`; local `origin/dev` has advanced to `980a9fbede123f411f52c8b061a05fb995ae159d`. The two destination-policy/outbound files are unchanged between them. + +- [src/lib/destination-policy.ts:191](/Users/jun/.codex/worktrees/ef41/opencodex/src/lib/destination-policy.ts:191): + ```ts + if (hextet >= 0xfc00 && hextet <= 0xfdff) + return { kind: "private", detail: "private-network address" }; + ``` + Thus `fdfe:dcba:9876::7e` is **private-network**, not `non-global` as the cached maintainer comment claims. + +- [src/lib/destination-policy.ts:138](/Users/jun/.codex/worktrees/ef41/opencodex/src/lib/destination-policy.ts:138): + ```ts + if (assessment?.kind !== "private" || assessment.detail !== "non-global address") return false; + ``` + The reported ULA fails before the explicit-zero mapped-address check. Existing exceptions cover IPv4 `198.18/15` and its supported IPv6 embeddings, not native Mihomo ULA. + +- [src/lib/provider-outbound.ts:157](/Users/jun/.codex/worktrees/ef41/opencodex/src/lib/provider-outbound.ts:157): + ```ts + allowBenchmarkAddresses: proxyConfigured && !noProxyMatches(parsed), + ``` + [src/codex/catalog/provider-fetch.ts:1668](/Users/jun/.codex/worktrees/ef41/opencodex/src/codex/catalog/provider-fetch.ts:1668) sends discovery through `providerOutboundPost/Get`; [destination-policy.ts:443](/Users/jun/.codex/worktrees/ef41/opencodex/src/lib/destination-policy.ts:443) admits only `isBenchmarkDnsAnswer(...)`. + +- **Do not broaden the shared helper blindly.** [src/server/management/provider-routes.ts:732](/Users/jun/.codex/worktrees/ef41/opencodex/src/server/management/provider-routes.ts:732): + ```ts + const allowBenchmarkAddresses = name === "openai" && isCanonicalOpenAiForwardProvider(provider); + ``` + This config-validation caller is not proxy-gated. + +- `gh pr diff 3489`: six changed files, **no change to destination-policy.ts**. Its outbound hunk changes admission to: + ```ts + allowBenchmarkAddresses: (proxyConfigured && !noProxyMatches(parsed)) + || transparentFakeIpException(url, parsed, isCanonicalUrl, name), + ``` + This enables existing benchmark classification for canonical discovery without proxy env. It does not recognize `fdfe:dcba:9876::/48`. Tests cover `198.18.0.29` and `::ffff:0:c612:1b`, not that ULA. + +- PR remains `APPROVED`, `CONFLICTING`, head `dbcfde8ca445c8dd04b04932904798664aae9cab`. Its old root-level test paths also need migration before any separate carry. +- `gh pr view 3489 --json commits`: all three commits name **`opencodex-fix `**, with empty GitHub login/id. This is the available commit email, **not verified account-linked attribution** for Flowershangfromthebranches. + +3) DIFF-LEVEL PLAN + +**MODIFY [src/lib/destination-policy.ts](/Users/jun/.codex/worktrees/ef41/opencodex/src/lib/destination-policy.ts)** + +Keep `classifyIpv6`, `isBenchmarkDnsAnswer`, literal validation, and config-time validation unchanged. Reuse `ipv6Hextets`; add one internal matcher: + +```ts +const MIHOMO_IPV6_FAKE_IP_PREFIX = [0xfdfe, 0xdcba, 0x9876] as const; + +function isMihomoIpv6FakeIpAnswer(address: string): boolean { + if (isIP(address) !== 6) return false; + const groups = ipv6Hextets(normalizeHostname(address)); + return groups !== null + && MIHOMO_IPV6_FAKE_IP_PREFIX.every((group, i) => groups[i] === group); +} +``` + +Extend only `resolvePublicAddresses` options with `allowMihomoIpv6FakeIp?: boolean`; derive a default-false local boolean. Change its DNS-answer exception: + +```diff +- if (benchmarkAllowed && isBenchmarkDnsAnswer(address, assessment)) { ++ if ((benchmarkAllowed && isBenchmarkDnsAnswer(address, assessment)) ++ || (mihomoIpv6Allowed ++ && assessment?.kind === "private" ++ && assessment.detail === "private-network address" ++ && isMihomoIpv6FakeIpAnswer(address))) { +``` + +Retain existing validated-address push and `continue`: accepted synthetic answers must not set `privateNetwork`. + +**MODIFY [src/lib/provider-outbound.ts](/Users/jun/.codex/worktrees/ef41/opencodex/src/lib/provider-outbound.ts)** + +```diff + allowBenchmarkAddresses: proxyConfigured && !noProxyMatches(parsed), ++ allowMihomoIpv6FakeIp: proxyConfigured && !noProxyMatches(parsed), +``` + +Document the independent flag: any later #3489 canonical/TUN exception must **not** enable it. The successful route continues fetching the original hostname with `redirect: "manual"` at lines 170–172. + +**MODIFY existing regression files; NEW files: none** + +- [tests/routing/destination-policy-resolved.test.ts](/Users/jun/.codex/worktrees/ef41/opencodex/tests/routing/destination-policy-resolved.test.ts): extend its DNS mock tests with: + - Accepted under the new flag: compressed, uppercase, expanded forms; `/48` lower/upper bounds and a nonzero fourth hextet. + - Rejected with no flag or **benchmark-only** flag. + - Literal `[fdfe:dcba:9876::7e]` still rejected. + - Adjacent `/48`s, ordinary ULA, loopback, metadata, link-local, RFC1918, and mixtures containing those remain rejected. + - Config validation with `allowBenchmarkAddresses: true` still rejects this ULA. + - An integrated `providerOutboundGet` case using this file’s real mocked-DNS resolver: explicit proxy succeeds through mocked native fetch; absent proxy and `NO_PROXY` reject without fetching or pin-connecting. + + Representative added assertion: + ```ts + lookupMock.mockResolvedValueOnce([{ address: "fdfe:dcba:9876::7e", family: 6 }]); + const result = await resolvePublicAddresses("https://opencode.ai/zen/v1/models", { + context: "provider URL", + allowMihomoIpv6FakeIp: true, + }); + expect(result.privateNetwork).toBe(false); + ``` + +- [tests/providers/provider-outbound.test.ts](/Users/jun/.codex/worktrees/ef41/opencodex/tests/providers/provider-outbound.test.ts): extend existing proxy/no-proxy option-capture tests to assert the new flag independently. + +Layout confirmed: `layout.json:577/927` and `test-layout-expected.json:414/764` already register these filenames under `routing` and `providers`. No manifest additions needed. + +Focused commands, **not run in this lane**: + +```bash +bun test tests/routing/destination-policy-resolved.test.ts +bun test tests/providers/provider-outbound.test.ts +``` + +**Docs MODIFY:** `docs-site/src/content/docs/reference/configuration/providers.md`, “Provider diagnostic outbound safety,” plus corresponding `ko`, `ja`, `zh-cn`, `zh-tw`, `fr`, `ru`, and `tr` pages. Explain exact-prefix DNS-only accommodation, configured-proxy requirement, `NO_PROXY` exclusion, and unchanged literal/private protections. + +**Risk:** C4 SSRF/credential-destination boundary; explicit security review required by [MAINTAINERS.md:60](/Users/jun/.codex/worktrees/ef41/opencodex/MAINTAINERS.md:60). No auth-store, credential formatting, or logging changes. Keep unpublished security analysis in scratch, not `devlog`. + +4) OPEN QUESTIONS / residual uncertainty + +- A ULA prefix alone cannot prove an answer is synthetic. A genuine private deployment could use the same `/48`. The proposed exception prevents direct local connections and preserves other private-address rejection, but still trusts the configured proxy’s final routing—as existing docs explicitly acknowledge. Do not claim collision-free or absolute SSRF equivalence. +- This fixes the reported **explicit-proxy discovery** scenario, not no-proxy IPv6 TUN or general provider-save validation. +- PR #3489 also claims public+benchmark mixtures reject; the inspected resolver loop accepts public answers alongside allowed benchmark answers. Do not reuse that claim without a regression. +- No edits, tests, Git writes, GitHub comments, or agents were executed. Memory informed attribution caution only; code/PR conclusions were checked live. + + diff --git a/devlog/_plan/260905_bug_triage_stack/030_wp4_launchd_stable_launcher.md b/devlog/_plan/260905_bug_triage_stack/030_wp4_launchd_stable_launcher.md new file mode 100644 index 0000000000..e65fde130e --- /dev/null +++ b/devlog/_plan/260905_bug_triage_stack/030_wp4_launchd_stable_launcher.md @@ -0,0 +1,165 @@ +# 030 — launchd stable-launcher parity (#3464) + +Work-phase: one full PABCD cycle. Refs #3464 (does not close: the PR fixes which version the *next* launchd start runs; an already-running stale proxy after an external upgrade is a separate product decision — auto-repair vs refuse — left open on the issue). +Source evidence lane output is reproduced below verbatim (diff-level). Stale-check +against the current tree at this cycle's P before implementing. + +--- + +1) VERDICT: FIXABLE + +Launchd parity is **not fixed**. The stable launcher fixes which version starts next; it does **not** replace an already-running proxy after `mise upgrade`. + +2) EVIDENCE + +Checkout HEAD is `6d9639165581546cdcebe96bc911446caabdd7d0`. Contrary to the supplied premise, local `origin/dev` is now `980a9fbede123f411f52c8b061a05fb995ae159d`. `git diff HEAD origin/dev -- src/service.ts tests/service/service.test.ts` is empty. + +Current code: + +- [src/service.ts:73](/Users/jun/.codex/worktrees/ef41/opencodex/src/service.ts:73): `cli: join(import.meta.dir, "cli", "index.ts")`. +- [src/service.ts:490](/Users/jun/.codex/worktrees/ef41/opencodex/src/service.ts:490): `const { bun, bunRuntimeSource, cli } = cliEntry();` +- [src/service.ts:507](/Users/jun/.codex/worktrees/ef41/opencodex/src/service.ts:507): `const command = buildServiceShellCommand(bun, cli);` +- [src/service.ts:515](/Users/jun/.codex/worktrees/ef41/opencodex/src/service.ts:515): ProgramArguments remain `/bin/sh`, `-lc`, `${plistString(command)}`. +- [src/service.ts:498](/Users/jun/.codex/worktrees/ef41/opencodex/src/service.ts:498): plist also stamps `${BUN_RUNTIME_SOURCE_ENV}` and `${BUN_RUNTIME_PATH_ENV}`. +- [src/service.ts:2254](/Users/jun/.codex/worktrees/ef41/opencodex/src/service.ts:2254): `writeServiceDefinitionFile(p, buildPlist(), "utf8");`; line 2271 records `writeServiceInstallState();` without a launcher. +- Linux already selects the alternate command at [src/service.ts:3301](/Users/jun/.codex/worktrees/ef41/opencodex/src/service.ts:3301): `launcher ? buildServiceLauncherShellCommand(launcher) : buildServiceShellCommand(bun, cli)`. +- Launcher-aware stale-path detection already exists at [src/service.ts:3244](/Users/jun/.codex/worktrees/ef41/opencodex/src/service.ts:3244): `if (state?.launcherPath) { if (existsSync(state.launcherPath)) return null; ... }`. + +Linux precedent, verified through GitHub and `git log origin/dev`: + +- [#2898](https://github.com/lidge-jun/opencodex/issues/2898) is the closed Linux issue, not a PR. +- [#2909](https://github.com/lidge-jun/opencodex/pull/2909), merged `4ec1cc9585456eac95f9c9d679b61380496d6d9d`, explicitly says “launchd and Windows service generation untouched.” +- [#2916](https://github.com/lidge-jun/opencodex/pull/2916), merged `fe05b0ae2c479056099c77185dba63d101e46f80`, hardens executable discovery, deterministic builders, and trusted Bun overrides. + +`git log origin/dev --since=2026-09-01 -- src/service.ts` contains only `ea29e25b0` (#3186), `b14b741dc` (#3134), and `330470e74` (#3118): wait accounting, Windows startup, and typed stop outcomes—not launchd launcher parity. + +3) DIFF-LEVEL PLAN + +**MODIFY [src/service.ts](/Users/jun/.codex/worktrees/ef41/opencodex/src/service.ts)** + +Mirror `buildUnit`’s explicit dependencies; resolve the launcher once during installation: + +```diff +-export function buildPlist(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string { +- const { bun, bunRuntimeSource, cli } = cliEntry(); ++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; +``` + +Replace the two unconditional runtime environment entries: + +```diff +- ` ${BUN_RUNTIME_SOURCE_ENV}${bunRuntimeSource}`, +- ` ${BUN_RUNTIME_PATH_ENV}${plistString(bun)}`, ++ ...(launcher ? [] : [ ++ ` ${BUN_RUNTIME_SOURCE_ENV}${bunRuntimeSource}`, ++ ` ${BUN_RUNTIME_PATH_ENV}${plistString(bun)}`, ++ ]), ++ launcher && runtime.source === "override" ++ ? ` ${runtime.overrideEnv}${plistString(runtime.path)}` ++ : null, +``` + +Preserve the existing shell wrapper, XML escaping, token-file preamble, port and home environment: + +```diff +- const command = buildServiceShellCommand(bun, cli); ++ const command = launcher ++ ? buildServiceLauncherShellCommand(launcher) ++ : buildServiceShellCommand(bun, cli); +``` + +In `installLaunchd()`: + +```diff +- writeServiceDefinitionFile(p, buildPlist(), "utf8"); ++ const launcher = stableLauncherEntry(); ++ writeServiceDefinitionFile(p, buildPlist(resolvedProxyEnv(), { launcher }), "utf8"); + // Existing unload/load and failure checks remain unchanged. +- writeServiceInstallState(); ++ writeServiceInstallState("scheduler", launcher); +``` + +Update Linux-only comments at lines 77 and 208 to cover launchd/systemd. No state-schema change or new diagnostic implementation is needed. + +**MODIFY [tests/service/service.test.ts](/Users/jun/.codex/worktrees/ef41/opencodex/tests/service/service.test.ts)** + +Extend existing `describe("launchd service plist")` at line 1049. Adapt the Linux regression at line 1159: + +```ts +test("launchd uses a stable launcher without pinning package paths (#3464)", () => { + const plist = buildPlist(resolvedProxyEnv({}), { + launcher: "/Users/test/.local/share/mise/shims/ocx", + runtime: { + path: "/opt/opencodex/versioned/bun", + source: "bundled", + overrideEnv: "OPENCODEX_BUN_PATH", + }, + }); + expect(plist).toContain("/Users/test/.local/share/mise/shims/ocx"); + 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); + expectTextToContainPath(plist, serviceApiTokenFilePath()); + expect(launchdListenPort({ readPlist: () => plist })) + .toBe(resolveServiceListenPort()); +}); +``` + +Also pin: + +- `launcher: null` retains direct Bun/CLI and provenance markers. +- Launcher mode preserves **trusted** `OPENCODEX_BUN_PATH`, rejects unpaired ambient overrides; extend the existing provenance test at line 1050. +- Custom homes/proxy settings survive, and a sentinel API token never appears in the plist. +- Install source-order assertion mirrors lines 384–391: one discovery, same launcher passed to plist and state, state written only after successful load. +- Retargeted-shim case extends lines 1187–1232 to assert the plist names the unchanged shim before/after removing v1. +- Quoted launcher paths containing spaces, apostrophes and `&` remain valid shell/XML. + +Platform convention: existing plist string tests are **cross-platform**, not Darwin-skipped. Only a new macOS-native parser/execution check should use `test.skipIf(process.platform !== "darwin")`, as in [tests/codex-integration/codex-log-guard-coderabbit.test.ts:111](/Users/jun/.codex/worktrees/ef41/opencodex/tests/codex-integration/codex-log-guard-coderabbit.test.ts:111). Do not invoke real launchctl from regression tests. + +No NEW test file required: existing mappings are `"service.test.ts": "service"` in [layout.json:1056](/Users/jun/.codex/worktrees/ef41/opencodex/scripts/test-layout/layout.json:1056) and [test-layout-expected.json:893](/Users/jun/.codex/worktrees/ef41/opencodex/tests/fixtures/test-layout-expected.json:893). A separately named test would require both entries. + +**Cheap optional diagnostic improvement** + +Detection already exists; its message incorrectly assumes the CLI is stale. [src/cli/version-skew.ts:43](/Users/jun/.codex/worktrees/ef41/opencodex/src/cli/version-skew.ts:43) says: + +```diff +- warning: `CLI ${cliVersion} does not match the running proxy ${proxy} — this ocx on PATH is stale. ` +- + "Its help and features describe a different build. Reinstall, or run the proxy's own binary.", ++ warning: `CLI ${cliVersion} does not match the running proxy ${proxy}. ` ++ + "After upgrading OpenCodex, run 'ocx service repair' for an installed service, " ++ + "or restart a foreground proxy. If the CLI is older, check the ocx installation on PATH.", +``` + +If included, MODIFY [tests/cli/cli-version-skew.test.ts](/Users/jun/.codex/worktrees/ef41/opencodex/tests/cli/cli-version-skew.test.ts): replace its `"stale"` assertion at line 17; test both version directions, particularly `2.42.0` versus `2.10.1-preview.20260805`. No new version parser or startup enforcement needed. + +**Documentation** + +- MODIFY [docs-site/src/content/docs/reference/cli/lifecycle.md:245](/Users/jun/.codex/worktrees/ef41/opencodex/docs-site/src/content/docs/reference/cli/lifecycle.md:245): “On Linux, the systemd unit…” → “On macOS and Linux, launchd and systemd service definitions…”. +- Replace line 256’s broad “subsequent version changes need no action” with: “After one repair migrates an old definition, subsequent starts follow the stable launcher. An already-running proxy still requires a restart after an external upgrade.” +- MODIFY [structure/04_transports-and-sidecars.md:36](/Users/jun/.codex/worktrees/ef41/opencodex/structure/04_transports-and-sidecars.md:36): generalize the Linux-only launcher contract to both POSIX service backends. Check translated lifecycle pages for contradictory claims. + +Focused verification commands, **not executed**: + +```sh +bun test tests/service/service.test.ts +bun test tests/cli/cli-version-skew.test.ts +``` + +Second command applies only if taking the optional wording change. + +Risk: explicit security review is warranted under [MAINTAINERS.md:60](/Users/jun/.codex/worktrees/ef41/opencodex/MAINTAINERS.md:60). This changes durable executable selection and Bun override propagation adjacent to credential loading. Preserve absolute executable validation, lexical paths, shell/XML escaping, file-backed tokens and successful-load-before-state ordering. + +4) OPEN QUESTIONS / RESIDUAL UNCERTAINTY + +- Existing plists need one `ocx service repair`; the new generator cannot migrate them merely by being installed. +- Stable-launcher parity does **not** satisfy automatic repair/request refusal during an already-running version mismatch. That remains a separate product decision. +- Actual mise shim selection under launchd’s login-shell environment needs a macOS smoke check; no live service or real upgrade was exercised. +- Read-only investigation only: no files, Git state, GitHub comments, service state or tests were changed/run. diff --git a/devlog/_plan/260905_bug_triage_stack/040_wp5_spill_write_health.md b/devlog/_plan/260905_bug_triage_stack/040_wp5_spill_write_health.md new file mode 100644 index 0000000000..f64f9d7c98 --- /dev/null +++ b/devlog/_plan/260905_bug_triage_stack/040_wp5_spill_write_health.md @@ -0,0 +1,162 @@ +# 040 — Continuation spill write health projection (#3522) + +Work-phase: one full PABCD cycle. Closes (references #3522; stays open per maintainer plan). Carries PR #3525 (supersedes; Co-authored-by: Ingwannu ). +Source evidence lane output is reproduced below verbatim (diff-level). Stale-check +against the current tree at this cycle's P before implementing. + +--- + +## 1) VERDICT: CARRY_EXISTING_PR + +Carry [PR #3525](https://github.com/lidge-jun/opencodex/pull/3525), head `288506dc6883fa8433cf89014e72d01c1675317d`. It is **OPEN / CONFLICTING**, not implemented on inspected HEAD `6d9639165` (origin/dev had moved to `980a9fbed`; the eight PR-touched paths are identical between the two). + +## 2) EVIDENCE + +Current-dev anchors: + +- [src/responses/state.ts:383](/Users/jun/.codex/worktrees/ef41/opencodex/src/responses/state.ts:383): + ```ts + } catch { + if (ref) deleteResponseSpill(ref); + if (states.get(job.id) === candidate && !job.cancelled) { + spillCounters.writeFailures += 1; + replaceWithSpillFailure(job.id, candidate); + ``` + Exception discarded; no failure class, timestamp, or streak. + +- [src/responses/state.ts:2151](/Users/jun/.codex/worktrees/ef41/opencodex/src/responses/state.ts:2151): + ```ts + spillWrites: spillCounters.writes, + spillWriteFailures: spillCounters.writeFailures, + spillReadFailures: spillCounters.readFailures, + replayScopeMismatchDrops, + ``` + The interface at line 2099 contains only the existing 12 numeric fields. + +- [src/server/management/system-routes.ts:120](/Users/jun/.codex/worktrees/ef41/opencodex/src/server/management/system-routes.ts:120): `"responseState: responseStateMetrics(),"`. Its health route independently returns `"status: \"ok\""` at line 57. + +- [tests/server/memory-watchdog.test.ts:220](/Users/jun/.codex/worktrees/ef41/opencodex/tests/server/memory-watchdog.test.ts:220): + ```ts + expect(responseStateValues).toHaveLength(12); + expect(responseStateValues.every(value => + typeof value === "number" && Number.isFinite(value))).toBe(true); + ``` + +Conflict analysis: + +- PR parent: `99fc38c39d9dc9a9ba76f87e9afefe3e8fb301c0`. +- All **seven other PR-touched paths are byte-identical** between that parent and current dev (`git diff HEAD -- ` is empty). +- The eighth path moved in **`79e03643d7cfa2b6c3c4eb8afd6179a140b197a3`, PR #3518**: + `tests/memory-watchdog.test.ts` → `tests/server/memory-watchdog.test.ts`. +- Cross-path diff proves only import rebasing: `../src/` → `../../src/`, `./helpers/management-auth` → `../helpers/management-auth`. Thus the carry needs path adaptation, not runtime reconciliation. GitHub’s precise conflict-marker output was not generated. + +## 3) DIFF-LEVEL PLAN + +**MODIFY these existing files; NEW: none.** + +### Runtime + +[src/responses/state.ts](/Users/jun/.codex/worktrees/ef41/opencodex/src/responses/state.ts) + +Carry PR’s health state, classifier, helpers, metrics, and reset logic: + +```diff +- spillCounters.writes += 1; ++ noteSpillWriteSuccess(); // increments writes; clears streak; records now() + +- } catch { +- spillCounters.writeFailures += 1; ++ } catch (error) { ++ noteSpillWriteFailure(error); // increments cumulative + streak; records class/time +``` + +Apply across async publication, shutdown fallback, atomic replacement, oversized admission, pruning, and budget eviction—not just `runPendingResponseSpill`. Keep existing cancellation/generation guards. + +```diff + spillWriteFailures: spillCounters.writeFailures, ++ spillWriteStatus: spillWriteHealth.consecutiveFailures > 0 ++ ? "degraded" ++ : spillWriteHealth.lastSuccessAt !== null ? "healthy" : "initial", ++ spillWriteConsecutiveFailures: spillWriteHealth.consecutiveFailures, ++ spillLastWriteFailureCode: spillWriteHealth.lastFailureCode, ++ spillLastWriteFailureAt: spillWriteHealth.lastFailureAt, ++ spillLastWriteSuccessAt: spillWriteHealth.lastSuccessAt, +``` + +Classifier: bounded four-level `cause` traversal; closed classes `EACLRETRYEXHAUSTED`, `ETIMEDOUT`, `EACCES`, `ENOSPC`, `EFBIG`, `EIO`, `ECAPACITY`, `ELOOP`, `EUNKNOWN`; map `EPERM→EACCES`, `EDQUOT→ENOSPC`. Never retain raw errors. + +Wrap the existing second ACL attempt to classify repeated timeout as exhausted recovery: + +```ts +catch (retryError) { + exhaustedAclRetry = isAclTimeout(retryError); + throw retryError; +} +// Existing outer catch: +noteSpillWriteFailure(error, + exhaustedAclRetry ? "EACLRETRYEXHAUSTED" : undefined); +``` + +[src/server/management/system-routes.ts](/Users/jun/.codex/worktrees/ef41/opencodex/src/server/management/system-routes.ts) + +Comment-only carry: numeric-only description → numeric/fixed-enum/timestamp description. Existing forwarding already exposes the new fields. **No auth, health, readiness, restart, or drain changes.** + +### Tests + +- **MODIFY** [tests/responses/responses-state.test.ts](/Users/jun/.codex/worktrees/ef41/opencodex/tests/responses/responses-state.test.ts): carry failure→failure→success and Windows exhausted-ACL→healthy-runner regressions; update initial/reset/privacy assertions. + ```ts + // After two failures: + { spillWriteFailures: 2, spillWriteConsecutiveFailures: 2, + spillWriteStatus: "degraded", spillLastWriteFailureAt: 1500 } + // After successful publication: + { spillWrites: 1, spillWriteFailures: 2, spillWriteConsecutiveFailures: 0, + spillWriteStatus: "healthy", spillLastWriteFailureAt: 1500, + spillLastWriteSuccessAt: 2000 } + ``` + Strengthen the carry with injected unknown/nested errors containing private sentinel text; assert classification and absence of raw message/path/cause in serialized metrics. + +- **MODIFY** [tests/server/memory-watchdog.test.ts](/Users/jun/.codex/worktrees/ef41/opencodex/tests/server/memory-watchdog.test.ts): transplant PR’s old-root hunks; preserve current imports. + ```diff + - expect(responseStateValues).toHaveLength(12); + + expect(Object.keys(body.responseState)).toHaveLength(17); + ``` + Replace all-number assertion with explicit status/code allowlists, nullable finite timestamps, and finite remaining numeric fields. + +- **MODIFY** [tests/responses/continuation-dedup.test.ts:314](/Users/jun/.codex/worktrees/ef41/opencodex/tests/responses/continuation-dedup.test.ts:314): field-count expectation `12 → 17`. + +Mappings already exist in [layout.json:768](/Users/jun/.codex/worktrees/ef41/opencodex/scripts/test-layout/layout.json:768), [layout.json:996](/Users/jun/.codex/worktrees/ef41/opencodex/scripts/test-layout/layout.json:996), and [expected fixture:605](/Users/jun/.codex/worktrees/ef41/opencodex/tests/fixtures/test-layout-expected.json:605). No registry changes needed because no new test file is proposed. + +Focused verification **to run after carry**, not executed here: + +```bash +bun test tests/responses/responses-state.test.ts +bun test tests/responses/continuation-dedup.test.ts +bun test tests/server/memory-watchdog.test.ts +bun test tests/responses/responses-state-write-amplification.test.ts +bun test tests/windows/windows-secret-acl.test.ts +``` + +### Documentation and attribution + +**MODIFY**, carrying PR hunks: + +- [docs-site/src/content/docs/reference/management-api.md](/Users/jun/.codex/worktrees/ef41/opencodex/docs-site/src/content/docs/reference/management-api.md): memory metrics description → include five health fields. +- [docs-site/src/content/docs/troubleshooting/windows-memory.md](/Users/jun/.codex/worktrees/ef41/opencodex/docs-site/src/content/docs/troubleshooting/windows-memory.md): add state transitions, privacy contract, authenticated `ocx observe memory --json`; clarify dashboard displays memory/size fields, not necessarily every diagnostic. +- [structure/05_gui-and-management-api.md](/Users/jun/.codex/worktrees/ef41/opencodex/structure/05_gui-and-management-api.md): document process-local projection and unchanged liveness. + +Translation follow-up: corresponding `ko/ja/zh-cn/zh-tw/fr/ru/tr` Windows-memory pages currently retain “same fields” dashboard wording; qualify it consistently if carrying the English clarification. + +Verified commit author: +```text +Co-authored-by: Ingwannu +``` + +**Risk:** additive diagnostic contract changes numeric-only consumers. Privacy/security review is required for the new error projection under [MAINTAINERS.md:60](/Users/jun/.codex/worktrees/ef41/opencodex/MAINTAINERS.md:60). No credential/auth logic needs modification. Preserve fail-closed ACL checks, bounded retries, publication ordering, cancellation semantics, and replay behavior. + +## 4) OPEN QUESTIONS / RESIDUAL UNCERTAINTY + +- This fixes **observability**, not the unproven current-version Windows persistence failure. Cached maintainer comments explicitly keep #3522 open pending an exercised current-version snapshot. +- `EACLRETRYEXHAUSTED` was not captured in the original live incident; do not present it as established root cause. +- PR tests were inspected, not executed. No files, Git state, or GitHub state were changed. + + diff --git a/devlog/_plan/260905_bug_triage_stack/050_wp6_codex_toggle_truth.md b/devlog/_plan/260905_bug_triage_stack/050_wp6_codex_toggle_truth.md new file mode 100644 index 0000000000..df7fddb9ef --- /dev/null +++ b/devlog/_plan/260905_bug_triage_stack/050_wp6_codex_toggle_truth.md @@ -0,0 +1,145 @@ +# 050 — Truthful Codex dashboard toggle (#3406) + +Work-phase: one full PABCD cycle. Closes #3406. Carries PR #3407 (supersedes; Co-authored-by: turin ). +Source evidence lane output is reproduced below verbatim (diff-level). Stale-check +against the current tree at this cycle's P before implementing. + +--- + +## 1) VERDICT: CARRY_EXISTING_PR + +#3406 is **not fixed**. Carry [PR #3407](https://github.com/lidge-jun/opencodex/pull/3407), preserving turin-dev’s credit. + +Baseline correction: checkout HEAD is `6d9639165581546cdcebe96bc911446caabdd7d0`; local `origin/dev` is `980a9fbede123f411f52c8b061a05fb995ae159d`. Their diff does not touch the affected files. No files changed or tests run. + +## 2) EVIDENCE + +Current code retains all three defects: + +- [IntegrationsOverview.tsx:691](/Users/jun/.codex/worktrees/ef41/opencodex/gui/src/pages/integrations/IntegrationsOverview.tsx:691): + `pendingToggle.toggle === "claude-desktop" ? DESKTOP_DISABLE_COPY : GROK_DISABLE_COPY` +- [overview-clients.ts:191](/Users/jun/.codex/worktrees/ef41/opencodex/gui/src/pages/integrations/overview-clients.ts:191): + `function codexRow(payload: CodexRoutingPayload | null)` — no native desired-state input; line 198: `togglePath: null`. +- [IntegrationsOverview.tsx:431](/Users/jun/.codex/worktrees/ef41/opencodex/gui/src/pages/integrations/IntegrationsOverview.tsx:431): + `refreshNativeDetails` refreshes `nativeResource`, `claudeResource`, `grokResource` only. Successful Codex mutation calls it at line 467; **Codex routing is not refreshed**. +- [native-integration-routes.ts:686](/Users/jun/.codex/worktrees/ef41/opencodex/src/server/management/native-integration-routes.ts:686): + `codexStatus(config, getConfigPath())` — OpenCodex’s path. +- [native-integration-routes.ts:165](/Users/jun/.codex/worktrees/ef41/opencodex/src/server/management/native-integration-routes.ts:165): + `const desiredEnabled = config.clientIntegrations?.codex !== false;` — desired state already exists server-side. + +Live PR metadata: **OPEN, draft, CONFLICTING**, head `38d45300a644dd0aa641a0a9b76f293169ab8ef9`. `gh pr view --json commits` confirms: + +```text +Co-authored-by: turin +``` + +## 3) DIFF-LEVEL CARRY PLAN + +### MODIFY runtime/UI owners + +**[IntegrationsOverview.tsx](/Users/jun/.codex/worktrees/ef41/opencodex/gui/src/pages/integrations/IntegrationsOverview.tsx)** — carry the PR’s localized Codex copy object and these edits: + +```diff + const refreshNativeDetails = () => { + nativeResource.refresh(); ++ codexResource.refresh(); + claudeResource.refresh(); + grokResource.refresh(); + }; +``` + +```diff +- pendingToggle.toggle === "claude-desktop" ? DESKTOP_DISABLE_COPY : GROK_DISABLE_COPY ++ pendingToggle.toggle === "claude-desktop" ? DESKTOP_DISABLE_COPY ++ : pendingToggle.id === "codex" ? CODEX_DISABLE_COPY ++ : GROK_DISABLE_COPY +``` + +Also use `const toggleOn = row.toggleOn ?? row.applied` for **both** switch value and accessible action label; include Codex in native-refusal copy handling. + +**[overview-clients.ts](/Users/jun/.codex/worktrees/ef41/opencodex/gui/src/pages/integrations/overview-clients.ts)**: + +```diff +-function codexRow(payload: CodexRoutingPayload | null): OverviewRow { ++function codexRow( ++ payload: CodexRoutingPayload | null, ++ native: NativeStatus | undefined, ++ nativeSettled: boolean | undefined, ++): OverviewRow { +``` + +```diff +- toggleBlocked: null, +- togglePath: null, ++ toggleBlocked: native?.disableBlocked ?? null, ++ togglePath: native?.configPath ?? null, +``` + +Carry the PR branches: unsettled native read → unknown; settled-but-missing native row → no toggle; `installed` from native status; `toggleOn: native.desiredEnabled`; **badge/applied remain derived from `routingInjected`**. Pass the Codex native row and `sources.nativeSettled` from `buildOverviewRows`. + +**[native-integration-routes.ts](/Users/jun/.codex/worktrees/ef41/opencodex/src/server/management/native-integration-routes.ts)**: + +```diff ++import { join } from "node:path"; ++import { getCodexHome } from "../../codex/paths"; +… +- codexStatus(config, getConfigPath()) ++ codexStatus(config, join(getCodexHome(), "config.toml")) +``` + +Leave Claude’s `getConfigPath()` and PUT/convergence logic unchanged. + +### MODIFY translations/docs/tests + +- Add the PR’s six `integrations.dialog.codex.*` keys to every existing locale: `en`, `de`, `fr`, `ja`, `ko`, `ru`, `tr`, `zh-TW`, `zh` under [gui/src/i18n](/Users/jun/.codex/worktrees/ef41/opencodex/gui/src/i18n). +- [codex-integration.md](/Users/jun/.codex/worktrees/ef41/opencodex/docs-site/src/content/docs/guides/codex-integration.md): carry the desired-switch/observed-badge and disable/re-enable paragraph; retain current voice-sideband documentation. Keep corresponding locale guides consistent. +- [integrations-overview-rows.test.ts](/Users/jun/.codex/worktrees/ef41/opencodex/gui/tests/integrations-overview-rows.test.ts): native fixtures; desired false while routing remains injected; effective path; disabled/absent result. Add unsettled/missing-native cases. +- [integrations-surfaces.test.tsx](/Users/jun/.codex/worktrees/ef41/opencodex/gui/tests/integrations-surfaces.test.tsx): carry mounted regression proving Codex title/path, no PUT before confirmation, correct PUT, and refreshed switch **and badge**. +- [overview-state-merge.test.ts](/Users/jun/.codex/worktrees/ef41/opencodex/gui/tests/overview-state-merge.test.ts): add `desiredEnabled: true` to native fixture. +- [native-codex-toggle.test.ts](/Users/jun/.codex/worktrees/ef41/opencodex/tests/codex-integration/native-codex-toggle.test.ts): carry isolated `CODEX_HOME` directory setup/environment restoration and effective-config-path assertion. + +**Layout correction:** do not recreate `tests/native-codex-toggle.test.ts` or invent a server duplicate. The existing test moved in `8b6e4542a` / #3516. Both [layout.json:798](/Users/jun/.codex/worktrees/ef41/opencodex/scripts/test-layout/layout.json:798) and [test-layout-expected.json:635](/Users/jun/.codex/worktrees/ef41/opencodex/tests/fixtures/test-layout-expected.json:635) map it to `codex-integration`. Existing-file modifications need no manifest edits; any new test needs both entries. + +### Conflict handling + +Verified base-to-current differences: + +- `8b30d60b3` reverted the collapsed-client UI and lifted `statesResource` prop. **Keep current all-client rendering and current `mountOverview()` signature**; carry only Codex hunks. +- `413227888` / #3477 added journal deletion state/dialog adjacent to `pendingToggle`. Preserve it. +- Backend route and `overview-clients.ts` have no base-to-current changes; their carry hunks are straightforward. +- Preserve current locale additions and voice-sideband docs; do not replace whole files with PR versions. +- Retarget the moved backend test’s imports to existing `../../src/...` and `../helpers/...`. + +### Focused verification plan—not executed + +From repository root: + +```bash +bun test tests/codex-integration/native-codex-toggle.test.ts +bun test tests/server/server-management-auth.test.ts +``` + +From `gui/`: + +```bash +bun test tests/integrations-overview-rows.test.ts +bun test tests/overview-state-merge.test.ts +bun test tests/integrations-surfaces.test.tsx +bun run lint:i18n +bun run build +``` + +No repository-wide suite. + +Security review is required: `src/AGENTS.md` explicitly classifies management API changes as security-boundary changes; [MAINTAINERS.md:59](/Users/jun/.codex/worktrees/ef41/opencodex/MAINTAINERS.md:59) requires explicit review. This patch changes path reporting, not credentials or authorization. Preserve auth gates; keep screenshot paths synthetic. + +**NEW asset if carrying:** the PR’s `docs/pr-assets/3407-codex-disable-dialog.png`, or fresh equivalent. [.github/PULL_REQUEST_TEMPLATE.md:8](/Users/jun/.codex/worktrees/ef41/opencodex/.github/PULL_REQUEST_TEMPLATE.md:8) requires a GUI screenshot **in the description**. #3407 already embeds one, but a carry should refresh it against the carried UI. Preserve Summary, Verification, Checklist, `Closes #3406`, and coauthor trailer. + +## 4) OPEN QUESTIONS / RESIDUAL UNCERTAINTY + +- Conflict analysis is source comparison, not a trial merge; exact unresolved merge hunks were not generated. +- PR test claims and screenshot are author evidence, not fresh verification. +- `getCodexHome()` re-resolves environment and can throw for an invalid directory; retain valid-directory fixtures and assess malformed-home behavior during security review. +- Parent must refresh its baseline before implementation; the stated `HEAD == origin/dev` no longer holds. + + diff --git a/devlog/_plan/260905_bug_triage_stack/060_ledger.md b/devlog/_plan/260905_bug_triage_stack/060_ledger.md new file mode 100644 index 0000000000..a9d73d4993 --- /dev/null +++ b/devlog/_plan/260905_bug_triage_stack/060_ledger.md @@ -0,0 +1,10 @@ +# 060 — Evidence ledger + +Append-only. One row per external action or verification. + +| When (UTC) | Item | Action | Evidence | +|---|---|---|---| +| 2026-09-04T22:28Z | manifest | live scan | 12 open bug issues, exactly the snapshot set | + +| 2026-09-04T22:53Z | #3467 / PR #3547 | pushed fd1dbbedb (--no-verify), PR opened base dev | typecheck 0; focused 48 pass; privacy ok; CI trailing | +| 2026-09-04T23:16Z | #3462 / PR #3551 | pushed 9611cdf4b (--no-verify), PR opened base #3547 head | typecheck 0; focused 71 pass; privacy ok; CI trailing | 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 5948574eea..61cb4451cb 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 298c41f0f0..e6679fd3e5 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 81cd158268..783c8739d4 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/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 380c1fb498..9780daa35f 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -349,6 +349,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 b07ea3b8bc..bfe1e3b194 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 c90ffc8dd5..e248694ab6 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 f3ca5d2ebd..d2256dcd1e 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 fba727e667..cbe9714fe7 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/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 4c47dd0233..7dc0745c99 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -65,6 +65,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..099668d97b 100644 --- a/tests/providers/provider-outbound.test.ts +++ b/tests/providers/provider-outbound.test.ts @@ -421,3 +421,101 @@ 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..f97c27cf7c 100644 --- a/tests/routing/destination-policy-resolved.test.ts +++ b/tests/routing/destination-policy-resolved.test.ts @@ -382,3 +382,77 @@ 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)"); + }); +}); +