diff --git a/devlog/_plan/260907_lane_c/000_plan.md b/devlog/_plan/260907_lane_c/000_plan.md new file mode 100644 index 0000000000..d26120ae29 --- /dev/null +++ b/devlog/_plan/260907_lane_c/000_plan.md @@ -0,0 +1,7 @@ +# Lane C release train roadmap + +Satisfy-spec HOTL, explicitly delegated by release-train main task. Goal: prepare five manual dependent PRs for main-session landing. No merge/release/publish/main/preview changes; no local tests, typecheck, build or install. All such checks NOT RUN. Remote Cross-platform CI dispatch lane=all at top head is the verifier. Stop after exact-head green CI, Astra review verdicts, screenshots, credit and SHA handoff; unresolved material blockers are reported with evidence. No user-specified token/cost/time bound. Tools: local scoped git/files, gh read/PR/push/CI, Astra explorer audits and browser inspection. New security findings stay in .tmp/lane-c. Main owns config-routes.ts; no edits there. Escalate cross-owner collisions; reclaim delegated slices after two distinct worker failures. + +Dependency order: roadmap → 3839 → 3841 → 3863 → 3860 → 3252/1533 → top CI and handoff. Lower-layer commit subjects include [skip ci]; stack:null. Every carry uses cherry-pick -x and source PR author Co-authored-by. Existing configuration field contracts are reused. Rollback is revert of a layer with descendant cascade, within main-authorized integration. Current source and read-only git/gh are evidence; no claimed local execution of product verifiers. Public original diffs are recorded in decade documents; private audit notes stay in scratch. + +Main steering: all gui/src/i18n/*.ts are append-only multiwriter; C adds namespaced keys at feature-section ends, never edits/deletes existing keys. Final cascade resolves append collisions. diff --git a/devlog/_plan/260907_lane_c/010_web_search.md b/devlog/_plan/260907_lane_c/010_web_search.md new file mode 100644 index 0000000000..ccb7217ea3 --- /dev/null +++ b/devlog/_plan/260907_lane_c/010_web_search.md @@ -0,0 +1,155 @@ +# 3839 implementation contract + +Carry public source patch with -x. Add deterministic 64KiB SSE and HTTP error-body regressions including cancel that never settles. Preserve complete prefix frames and discard incomplete tail. Tests use public run/parse APIs and controlled byte streams. + +Validation: local tests/typecheck/build/install NOT RUN by instruction. Read diff and source; top remote CI exercises changed test paths. Each conditional branch listed above is exercised by controlled fixtures; screenshot inspects GUI state. No new enforcement layer; existing API guards remain authoritative. + +## Public source diff (MODIFY/NEW paths) + +```diff +diff --git a/src/web-search/anthropic-executor.ts b/src/web-search/anthropic-executor.ts +index 1eb206afa..cd3893900 100644 +--- a/src/web-search/anthropic-executor.ts ++++ b/src/web-search/anthropic-executor.ts +@@ -5,7 +5,11 @@ import { CLAUDE_CODE_HEADERS, claudeCodeSessionId } from "../adapters/client-fin + import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort"; + import { sidecarEnter } from "../lib/sidecar-tracker"; + import { applyUpstreamRecoveryInit, fetchWithResetRetry } from "../lib/upstream-retry"; +-import type { WebSearchSource } from "./parse"; ++import { ++ MAX_SIDECAR_RESPONSE_BYTES, ++ cancelReaderWithoutWaiting, ++ type WebSearchSource, ++} from "./parse"; + import { BASE_INSTRUCTION, IMAGE_INSTRUCTION, type SidecarOutcome, type SidecarSettings } from "./executor"; + + /** Hardcoded per-turn search bound handed to the server tool (mirrors the loop's maxSearches intent). */ +@@ -17,6 +21,33 @@ function isRec(v: unknown): v is Record { + return !!v && typeof v === "object" && !Array.isArray(v); + } + ++/** Read at most `MAX_SIDECAR_RESPONSE_BYTES` of an untrusted upstream body, then stop reading. */ ++async function readBoundedText(res: Response): Promise { ++ if (!res.body) return ""; ++ const reader = res.body.getReader(); ++ const decoder = new TextDecoder(); ++ let out = ""; ++ let seen = 0; ++ try { ++ for (;;) { ++ const { done, value } = await reader.read(); ++ if (done) break; ++ const remaining = MAX_SIDECAR_RESPONSE_BYTES - seen; ++ const accepted = value.byteLength <= remaining ? value : value.subarray(0, remaining); ++ seen += accepted.byteLength; ++ out += decoder.decode(accepted, { stream: true }); ++ if (seen >= MAX_SIDECAR_RESPONSE_BYTES) { ++ cancelReaderWithoutWaiting(reader, "sidecar error body byte limit reached"); ++ break; ++ } ++ } ++ out += decoder.decode(); ++ } catch { ++ /* a failed error-body read must not mask the HTTP status we are about to report */ ++ } ++ return out; ++} ++ + /** + * Fold an Anthropic Messages SSE stream (a web_search_20250305 turn) into a WebSearchResult. + * +@@ -41,6 +72,7 @@ export async function parseAnthropicSidecarSSE(res: Response): Promise): void => { + const type = typeof data.type === "string" ? data.type : ""; +@@ -82,15 +114,27 @@ export async function parseAnthropicSidecarSSE(res: Response): Promise= MAX_SIDECAR_RESPONSE_BYTES) { ++ // Keep the frames already folded above, drop the unterminated tail, and do not wait on ++ // upstream teardown. ++ cancelReaderWithoutWaiting(reader, "sidecar response byte limit reached"); ++ buffer = ""; ++ break; ++ } + } + // Flush the decoder and process any final unterminated frame (a stream that ends without \n\n). + buffer = (buffer + decoder.decode()).replace(/\r\n/g, "\n"); +@@ -177,7 +221,9 @@ export async function runAnthropicWebSearch( + // (found investigating #1419). + const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); + if (!res.ok) { +- const t = await res.text().catch(() => ""); ++ // Untrusted upstream error bodies are only used for an auth-failure message, so read a ++ // bounded prefix instead of buffering an arbitrarily large response. ++ const t = await readBoundedText(res); + detachBodyGuard(); + console.warn(`[web-search] anthropic sidecar HTTP ${res.status} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`); + if (res.status === 401) { +diff --git a/src/web-search/parse.ts b/src/web-search/parse.ts +index 757c309f3..7ba5d2607 100644 +--- a/src/web-search/parse.ts ++++ b/src/web-search/parse.ts +@@ -193,7 +193,7 @@ function fromOutputArray(output: OutputItem[], seen: Set): WebSearchResu + return { text, sources }; + } + +-function cancelReaderWithoutWaiting( ++export function cancelReaderWithoutWaiting( + reader: ReadableStreamDefaultReader, + reason: string, + ): void { +diff --git a/tests/web-search/web-search-anthropic.test.ts b/tests/web-search/web-search-anthropic.test.ts +index f5b7f1df2..33f2616cc 100644 +--- a/tests/web-search/web-search-anthropic.test.ts ++++ b/tests/web-search/web-search-anthropic.test.ts +@@ -130,6 +130,27 @@ describe("parseAnthropicSidecarSSE", () => { + expect(out.error).toBeDefined(); + }); + ++ test("an unterminated frame cannot buffer the stream without bound", async () => { ++ // A sidecar that never emits a frame separator: without a cap the parser would accumulate ++ // the whole stream in memory before it could fold anything. ++ let produced = 0; ++ let cancelled = false; ++ const chunk = new TextEncoder().encode(`data: {"filler":"${"x".repeat(64 * 1024)}"}`); ++ const body = new ReadableStream({ ++ pull(c) { ++ if (produced > 8 * 1024 * 1024) { c.close(); return; } ++ produced += chunk.byteLength; ++ c.enqueue(chunk); ++ }, ++ cancel() { cancelled = true; }, ++ }); ++ const out = await parseAnthropicSidecarSSE(new Response(body, { status: 200 })); ++ expect(cancelled).toBe(true); ++ // The cap stops the read long before the producer would have finished on its own. ++ expect(produced).toBeLessThan(1024 * 1024); ++ expect(out.text).toBe(""); ++ }); ++ + test("empty results (content:[]) with answer text is a success, not an error", async () => { + const res = sseResponse([ + { type: "content_block_start", index: 0, content_block: { type: "web_search_tool_result", tool_use_id: "srvtoolu_3", content: [] } }, + +``` diff --git a/devlog/_plan/260907_lane_c/020_vision.md b/devlog/_plan/260907_lane_c/020_vision.md new file mode 100644 index 0000000000..9dab5d18e2 --- /dev/null +++ b/devlog/_plan/260907_lane_c/020_vision.md @@ -0,0 +1,135 @@ +# 3841 implementation contract + +Carry public source patch with -x. Add 64KiB HTTP error-body and non-settling cancel regressions. Preserve complete description frames before cap; discard unfinished frame even at exact cap; retain downstream clamp. No credential-policy changes. + +Validation: local tests/typecheck/build/install NOT RUN by instruction. Read diff and source; top remote CI exercises changed test paths. Each conditional branch listed above is exercised by controlled fixtures; screenshot inspects GUI state. No new enforcement layer; existing API guards remain authoritative. + +## Public source diff (MODIFY/NEW paths) + +```diff +diff --git a/src/vision/anthropic-describe.ts b/src/vision/anthropic-describe.ts +index 4f41017ef..280096f03 100644 +--- a/src/vision/anthropic-describe.ts ++++ b/src/vision/anthropic-describe.ts +@@ -10,6 +10,8 @@ import type { DescribeOutcome, VisionSettings } from "./describe"; + const ANTHROPIC_VISION_MAX_TOKENS = 1024; + const ALLOWED_IMAGE_MIME = new Set(["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"]); + const MAX_IMAGE_BYTES = 20 * 1024 * 1024; ++/** Bound the sidecar SSE stream and its untrusted error body; the description is clamped downstream. */ ++const MAX_SIDECAR_RESPONSE_BYTES = 64 * 1024; + const DESCRIBE_INSTRUCTION = + "You are a vision describer for a text-only model that cannot see the image. Describe the image " + + "thoroughly and factually so that model can fully reason about it: transcribe any visible text " + +@@ -43,6 +45,34 @@ function buildImageBlock(imageUrl: string): { block?: AnthropicImageBlock; error + return { error: "unsupported image URL scheme (expected data: or https:)" }; + } + ++/** Read at most `MAX_SIDECAR_RESPONSE_BYTES` of an untrusted upstream body, then stop reading. */ ++async function readBoundedText(res: Response): Promise { ++ if (!res.body) return ""; ++ const reader = res.body.getReader(); ++ const decoder = new TextDecoder(); ++ let out = ""; ++ let seen = 0; ++ try { ++ for (;;) { ++ const { done, value } = await reader.read(); ++ if (done) break; ++ const remaining = MAX_SIDECAR_RESPONSE_BYTES - seen; ++ const accepted = value.byteLength <= remaining ? value : value.subarray(0, remaining); ++ seen += accepted.byteLength; ++ out += decoder.decode(accepted, { stream: true }); ++ if (seen >= MAX_SIDECAR_RESPONSE_BYTES) { ++ try { void reader.cancel("vision sidecar error body byte limit reached").catch(() => undefined); } ++ catch { /* best-effort body teardown */ } ++ break; ++ } ++ } ++ out += decoder.decode(); ++ } catch { ++ /* a failed error-body read must not mask the HTTP status we are about to report */ ++ } ++ return out; ++} ++ + /** Fold Anthropic Messages text deltas into one description. Malformed frames are ignored. */ + export async function parseAnthropicVisionSSE(res: Response): Promise { + if (!res.body) return { text: "", error: "anthropic vision sidecar returned no response body" }; +@@ -52,6 +82,7 @@ export async function parseAnthropicVisionSSE(res: Response): Promise { + let dataLine = ""; +@@ -76,12 +107,24 @@ export async function parseAnthropicVisionSSE(res: Response): Promise= MAX_SIDECAR_RESPONSE_BYTES) { ++ // Keep the frames folded above, drop the unterminated tail, and do not wait on teardown. ++ try { void reader.cancel("vision sidecar response byte limit reached").catch(() => undefined); } ++ catch { /* best-effort body teardown */ } ++ buffer = ""; ++ break; ++ } + } + buffer = (buffer + decoder.decode()).replace(/\r\n/g, "\n"); + if (buffer.trim()) processFrame(buffer); +@@ -164,7 +207,8 @@ export async function describeImageAnthropic( + { abortSignal: linkedSignal.signal, label: "vision-sidecar-anthropic" }, + ); + if (!res.ok) { +- const responseText = await res.text().catch(() => ""); ++ // The body is untrusted and only feeds one auth-failure message, so read a bounded prefix. ++ const responseText = await readBoundedText(res); + console.warn(`[vision] anthropic sidecar HTTP ${res.status} (${Date.now() - startedAt}ms)`); + if (res.status === 401) { + return { text: "", error: `anthropic vision sidecar auth failed: ${publicOAuthAuthenticationErrorMessage(new Error(responseText))}` }; +diff --git a/tests/vision/vision-anthropic.test.ts b/tests/vision/vision-anthropic.test.ts +index ee4b01b42..30ed17af9 100644 +--- a/tests/vision/vision-anthropic.test.ts ++++ b/tests/vision/vision-anthropic.test.ts +@@ -225,6 +225,27 @@ describe("Anthropic vision executor", () => { + expect(result).toEqual({ text: "first second" }); + }); + ++ test("an unterminated frame cannot buffer the stream without bound", async () => { ++ // A sidecar that never emits a frame separator: without a cap the parser accumulates the ++ // whole response in memory before it can fold anything. ++ let produced = 0; ++ let cancelled = false; ++ const chunk = new TextEncoder().encode(`data: {"filler":"${"x".repeat(64 * 1024)}"}`); ++ const body = new ReadableStream({ ++ pull(c) { ++ if (produced > 8 * 1024 * 1024) { c.close(); return; } ++ produced += chunk.byteLength; ++ c.enqueue(chunk); ++ }, ++ cancel() { cancelled = true; }, ++ }); ++ const out = await parseAnthropicVisionSSE(new Response(body, { status: 200 })); ++ expect(cancelled).toBe(true); ++ // The cap stops the read long before the producer would have finished on its own. ++ expect(produced).toBeLessThan(1024 * 1024); ++ expect(out.text).toBe(""); ++ }); ++ + test("malformed and terminal-error streams degrade to explicit errors", async () => { + const malformed = await parseAnthropicVisionSSE(sseResponse(["{not-json", { type: "message_stop" }])); + expect(malformed.text).toBe(""); + +``` diff --git a/devlog/_plan/260907_lane_c/030_health.md b/devlog/_plan/260907_lane_c/030_health.md new file mode 100644 index 0000000000..eaeb4bbcd6 --- /dev/null +++ b/devlog/_plan/260907_lane_c/030_health.md @@ -0,0 +1,123 @@ +# 3863 implementation contract + +Carry with -x excluding config-routes.ts. getStartupHealthSnapshot returns fresh cached value unchanged; stale/empty read schedules refresh and returns immediately. Catch rejected or synchronously thrown detached probe and retain stale conservative health; invalidation generation cannot overwrite newer reading. Replace 100ms production settings assertion with controlled probe fixtures. Exact route wiring remains main responsibility. + +Validation: local tests/typecheck/build/install NOT RUN by instruction. Read diff and source; top remote CI exercises changed test paths. Each conditional branch listed above is exercised by controlled fixtures; screenshot inspects GUI state. No new enforcement layer; existing API guards remain authoritative. + +## Public source diff (MODIFY/NEW paths) + +```diff +diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts +index 4d551a886..9ddd02300 100644 +--- a/src/server/management/config-routes.ts ++++ b/src/server/management/config-routes.ts +@@ -107,7 +107,7 @@ import type { PersistedUsageAttempt } from "../../usage/log"; + import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; + import { withProviderServiceTierDTO } from "./provider-capability-config"; + import { applySystemEnvToggle } from "../system-env"; +-import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache"; ++import { getCachedStartupHealth, getStartupHealthSnapshot, invalidateStartupHealthCache } from "../startup-health-cache"; + import { runWindowsTrayAction } from "../windows-tray-control"; + import { runStartupInstallAction, type StartupInstallAction } from "../startup-action-control"; + import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, resolveCodexRuntime } from "../../codex/runtime"; +@@ -329,7 +329,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise Promise; + } + ++/** ++ * Return the last completed probe immediately and refresh it in the background. ++ * ++ * Settings are consumed by several dashboard controls. They must not block on a ++ * Windows service-manager probe; the dedicated /api/startup-health route owns ++ * the fresh, bounded diagnostic read. ++ */ ++export function getStartupHealthSnapshot( ++ config: Pick, ++ deps: StartupHealthCacheDeps = {}, ++): StartupHealth { ++ const now = deps.now ?? Date.now; ++ if (!cached || now() - cached.timestamp >= CACHE_TTL_MS) refreshInBackground(config, deps); ++ return cached ? markStartupHealthDiagnosticStale(cached.value) : conservativeFallback(config); ++} ++ + export function markStartupHealthDiagnosticStale(value: StartupHealth): StartupHealth { + if (!value.localRoutingDependency) return { ...value, diagnosticStale: true }; + return { +diff --git a/tests/service/autostart-health.test.ts b/tests/service/autostart-health.test.ts +index 639f1b34c..48bb7b539 100644 +--- a/tests/service/autostart-health.test.ts ++++ b/tests/service/autostart-health.test.ts +@@ -3,7 +3,7 @@ import { deriveStartupHealth, formatStartupRoutingDetail, startupHealthSummary } + import { unusedProxyWarningLines } from "../../src/cli/status"; + import { classifyCodexRouting, hasInjectedCodexRouting } from "../../src/codex/inject"; + import { handleManagementAPI } from "../../src/server/management-api"; +-import { getCachedStartupHealth, invalidateStartupHealthCache, markStartupHealthDiagnosticStale } from "../../src/server/startup-health-cache"; ++import { getCachedStartupHealth, getStartupHealthSnapshot, invalidateStartupHealthCache, markStartupHealthDiagnosticStale } from "../../src/server/startup-health-cache"; + import type { OcxConfig } from "../../src/types"; + + const base = { +@@ -277,6 +277,43 @@ describe("Codex startup health", () => { + await pendingProbe; + invalidateStartupHealthCache(); + }); ++ ++ test("settings snapshot starts a probe without waiting for it", async () => { ++ invalidateStartupHealthCache(); ++ let releaseProbe!: (value: ReturnType) => void; ++ const pendingProbe = new Promise>(resolve => { ++ releaseProbe = resolve; ++ }); ++ ++ const health = getStartupHealthSnapshot( ++ { codexAutoStart: true }, ++ { probe: async () => pendingProbe }, ++ ); ++ ++ expect(health.diagnosticStale).toBe(true); ++ releaseProbe(deriveStartupHealth({ ...base, routingKind: "native" })); ++ await pendingProbe; ++ invalidateStartupHealthCache(); ++ }); ++ ++ test("settings GET uses the non-blocking startup-health snapshot in production", async () => { ++ invalidateStartupHealthCache(); ++ const url = new URL("http://localhost/api/settings"); ++ ++ const response = await Promise.race([ ++ handleManagementAPI( ++ new Request(url), ++ url, ++ { port: 10100, providers: {}, defaultProvider: "openai", codexAutoStart: true } as OcxConfig, ++ ), ++ new Promise(resolve => setTimeout(() => resolve(null), 100)), ++ ]); ++ ++ expect(response?.status).toBe(200); ++ const body = await response!.json() as { startupHealth?: { diagnosticStale?: boolean } }; ++ expect(body.startupHealth?.diagnosticStale).toBe(true); ++ invalidateStartupHealthCache(); ++ }); + }); + import { ManagementRequest as Request } from "../helpers/management-auth"; + + +``` + +## Main-owned route handoff + +At current dev, settings GET uses `startupHealth: await readStartupHealth(config)` at `src/server/management/config-routes.ts:332`. M changes only this settings read to the exported immediate snapshot and retains the dedicated `/api/startup-health` bounded read. Settings PUT at line 625 is separately present; it must remain reviewed explicitly rather than blindly replaced. C does not modify either call site. diff --git a/devlog/_plan/260907_lane_c/040_desktop.md b/devlog/_plan/260907_lane_c/040_desktop.md new file mode 100644 index 0000000000..ad7fb60177 --- /dev/null +++ b/devlog/_plan/260907_lane_c/040_desktop.md @@ -0,0 +1,404 @@ +# 3860 implementation contract + +Carry source patch plus skipped-sync correction with -x. Default false/absent OFF, true remains true; persist preference before sync and surface sync failures. All nine locales and existing screenshot. Independent auth boundary review confirms remote admission/upstream credentials unchanged. + +Validation: local tests/typecheck/build/install NOT RUN by instruction. Read diff and source; top remote CI exercises changed test paths. Each conditional branch listed above is exercised by controlled fixtures; screenshot inspects GUI state. No new enforcement layer; existing API guards remain authoritative. + +## Public source diff (MODIFY/NEW paths) + +```diff +diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md +index 7d66e72c3..ff2df04fd 100644 +--- a/docs-site/src/content/docs/guides/codex-integration.md ++++ b/docs-site/src/content/docs/guides/codex-integration.md +@@ -215,6 +215,15 @@ HTTP/SSE. + + ### Authless Codex Desktop (opt-in) + ++In **Dashboard → Overview**, **Open Codex without signing in** controls this existing ++opt-in preference. The switch defaults to **off** when the setting is absent or false; ++an existing explicit `codexDesktopAuthless: true` stays enabled. The dashboard saves ++the preference and runs a full sync. Restart Codex Desktop after changing it. ++If synchronization fails, the saved preference remains and the dashboard shows the error; ++retry **Sync** before restarting. Account-gated Desktop features may be unavailable ++when enabled. Upstream credentials, local eligibility, remote admission authentication ++and user-owned gateway settings retain their existing requirements. ++ + Codex Desktop shows its ChatGPT login screen whenever the active provider requires OpenAI auth. If + your OpenCodex setup never uses ChatGPT credentials (routed providers only, or a blocked + `chatgpt.com`), you can opt out of that gate: +diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts +index 5faab4b35..495104bd4 100644 +--- a/gui/src/i18n/de.ts ++++ b/gui/src/i18n/de.ts +@@ -299,6 +299,8 @@ export const de: Record = { + "models.staleBanner": "Codex zeigt eine ältere Modellliste als dieser Katalog. Starte Codex neu, um sie neu zu laden.", + "dash.codexAutoStart": "opencodex mit Codex starten", + "dash.codexAutoStartHint": "Erlaubt einem installierten Launcher-Shim, ocx ensure auszuführen. Diese Einstellung installiert keinen Neustartschutz; prüfe den effektiven Zustand unter Startsicherheit.", ++ "dash.codexDesktopAuthless": "Codex ohne Anmeldung öffnen", ++ "dash.codexDesktopAuthlessHint": "Standardmäßig aus. Überspringt die separate Desktop-Anmeldung bei geeigneten lokalen Verbindungen. Zugangsdaten für den Anbieter bleiben erforderlich. Codex nach einer Änderung neu starten. Kontogebundene Desktop-Funktionen können fehlen.", + "dash.searchModel": "Such-Sidecar-Modell", + "dash.searchModelHint": "Modell für web_search bei nicht über OpenAI gerouteten Modellen. Erfordert ChatGPT-Login.", + "dash.searchReasoning": "Such-Reasoning-Aufwand", +diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts +index c71208942..22a380785 100644 +--- a/gui/src/i18n/en.ts ++++ b/gui/src/i18n/en.ts +@@ -311,6 +311,8 @@ export const en = { + "models.staleBanner": "Codex is showing an older model list than this catalog. Restart Codex to reload it.", + "dash.codexAutoStart": "Start opencodex with Codex", + "dash.codexAutoStartHint": "Allows an installed launcher shim to run ocx ensure. This setting does not install restart protection; check Startup safety for the effective state.", ++ "dash.codexDesktopAuthless": "Open Codex without signing in", ++ "dash.codexDesktopAuthlessHint": "Off by default. Skip the separate Desktop sign-in for eligible local connections. Upstream credentials are still required. Restart Codex after changing this setting. Account-gated Desktop features may be unavailable.", + "dash.searchModel": "Search sidecar model", + "dash.searchModelHint": "Model used for web_search on non-OpenAI routed models. Requires ChatGPT login.", + "dash.searchReasoning": "Search reasoning effort", +diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts +index e1b3519ef..9f0f26517 100644 +--- a/gui/src/i18n/fr.ts ++++ b/gui/src/i18n/fr.ts +@@ -301,6 +301,8 @@ export const fr: Record = { + "models.staleBanner": "Codex affiche une liste de modèles plus ancienne que ce catalogue. Redémarrez Codex pour la recharger.", + "dash.codexAutoStart": "Démarrer opencodex avec Codex", + "dash.codexAutoStartHint": "Permet à un mécanisme de lancement installé d’exécuter ocx ensure. Ce réglage n’installe pas de protection au redémarrage ; consultez Sécurité du démarrage pour connaître l’état effectif.", ++ "dash.codexDesktopAuthless": "Ouvrir Codex sans se connecter", ++ "dash.codexDesktopAuthlessHint": "Désactivé par défaut. Ignore la connexion Desktop séparée pour les connexions locales admissibles. Les identifiants du fournisseur restent nécessaires. Redémarrez Codex après toute modification. Certaines fonctions Desktop liées au compte peuvent être indisponibles.", + "dash.searchModel": "Modèle auxiliaire de recherche", + "dash.searchModelHint": "Modèle utilisé pour web_search sur les modèles routés autres qu’OpenAI. Nécessite une connexion à ChatGPT.", + "dash.searchReasoning": "Effort de raisonnement pour la recherche", +diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts +index cf9483158..55a6fe249 100644 +--- a/gui/src/i18n/ja.ts ++++ b/gui/src/i18n/ja.ts +@@ -308,6 +308,8 @@ export const ja: Record = { + "models.staleBanner": "Codex はこのカタログより古いモデル一覧を表示しています。Codex を再起動すると読み直されます。", + "dash.codexAutoStart": "Codex と一緒に opencodex を起動", + "dash.codexAutoStartHint": "インストール済み launcher shim に ocx ensure の実行を許可します。この設定だけでは再起動保護はインストールされません。起動安全性で実際の状態を確認してください。", ++ "dash.codexDesktopAuthless": "ログインせずに Codex を開く", ++ "dash.codexDesktopAuthlessHint": "既定ではオフです。対象のローカル接続で Desktop の個別ログインを省略します。上流プロバイダーの認証情報は引き続き必要です。変更後は Codex を再起動してください。アカウントに依存する Desktop 機能が利用できない場合があります。", + "dash.searchModel": "検索サイドカーモデル", + "dash.searchModelHint": "非 OpenAI ルーティングモデルで web_search に使うモデル。ChatGPT ログインが必要です。", + "dash.searchReasoning": "検索の推論負荷", +diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts +index c1959482b..19285b150 100644 +--- a/gui/src/i18n/ko.ts ++++ b/gui/src/i18n/ko.ts +@@ -303,6 +303,8 @@ export const ko: Record = { + "models.staleBanner": "Codex가 이 카탈로그보다 오래된 모델 목록을 보여주고 있습니다. Codex를 재시작하면 새로 읽습니다.", + "dash.codexAutoStart": "Codex 실행 시 opencodex 시작", + "dash.codexAutoStartHint": "설치된 launcher shim이 ocx ensure를 실행하도록 허용합니다. 이 설정은 재부팅 보호를 설치하지 않으므로 시작 안전성에서 실제 상태를 확인하세요.", ++ "dash.codexDesktopAuthless": "로그인 없이 Codex 열기", ++ "dash.codexDesktopAuthlessHint": "기본값은 꺼짐입니다. 지원되는 로컬 연결에서 별도의 Desktop 로그인을 건너뜁니다. 업스트림 인증 정보는 여전히 필요합니다. 변경 후 Codex를 다시 시작하세요. 계정에 연결된 Desktop 기능을 사용하지 못할 수 있습니다.", + "dash.searchModel": "서치 사이드카 모델", + "dash.searchModelHint": "비-OpenAI 라우팅 모델의 web_search에 사용되는 모델입니다. ChatGPT 로그인 필요.", + "dash.searchReasoning": "서치 추론 강도", +diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts +index 0109f5ebd..87704912a 100644 +--- a/gui/src/i18n/ru.ts ++++ b/gui/src/i18n/ru.ts +@@ -308,6 +308,8 @@ export const ru: Record = { + "models.staleBanner": "Codex показывает список моделей старее этого каталога. Перезапустите Codex, чтобы перечитать его.", + "dash.codexAutoStart": "Запускать opencodex вместе с Codex", + "dash.codexAutoStartHint": "Разрешает установленному launcher shim выполнять ocx ensure. Эта настройка не устанавливает защиту перезапуска; проверьте фактическое состояние в разделе безопасности запуска.", ++ "dash.codexDesktopAuthless": "Открывать Codex без входа", ++ "dash.codexDesktopAuthlessHint": "По умолчанию выключено. Пропускает отдельный вход в Desktop для допустимых локальных подключений. Учётные данные провайдера по-прежнему нужны. После изменения перезапустите Codex. Функции Desktop, связанные с аккаунтом, могут быть недоступны.", + "dash.searchModel": "Модель сайдкара поиска", + "dash.searchModelHint": "Модель, используемая для web_search на маршрутизируемых моделях, отличных от OpenAI. Требуется вход в аккаунт ChatGPT.", + "dash.searchReasoning": "Уровень рассуждений для поиска", +diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts +index fa8b8e9c2..807eeae32 100644 +--- a/gui/src/i18n/tr.ts ++++ b/gui/src/i18n/tr.ts +@@ -309,6 +309,8 @@ export const tr: Record = { + "models.staleBanner": "Codex, bu katalogdan daha eski bir model listesi gösteriyor. Yeniden okumak için Codex'i yeniden başlatın.", + "dash.codexAutoStart": "opencodex'i Codex ile başlat", + "dash.codexAutoStartHint": "Yüklü bir shim'in ocx ensure çalıştırmasına izin verir. Arka plan servisi veya yeniden başlatma koruması kurmaz; sistem durumu için Başlatma Güvenliği'ne bakın.", ++ "dash.codexDesktopAuthless": "Codex’i oturum açmadan başlat", ++ "dash.codexDesktopAuthlessHint": "Varsayılan olarak kapalıdır. Uygun yerel bağlantılarda ayrı Desktop oturum açma adımını atlar. Sağlayıcı kimlik bilgileri yine gereklidir. Değişiklikten sonra Codex’i yeniden başlatın. Hesaba bağlı Desktop özellikleri kullanılamayabilir.", + "dash.searchModel": "Arama yan araç modeli", + "dash.searchModelHint": "OpenAI dışı yönlendirilen modellerde web_search için kullanılan model. ChatGPT girişi gerektirir.", + "dash.searchReasoning": "Arama akıl yürütme çabası", +diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts +index 3bc246543..62e1f0711 100644 +--- a/gui/src/i18n/zh-TW.ts ++++ b/gui/src/i18n/zh-TW.ts +@@ -200,6 +200,8 @@ export const zhTW: Record = { + "models.staleBanner": "Codex 顯示的模型清單比目前的目錄舊。重新啟動 Codex 即可重新讀取。", + "dash.codexAutoStart": "隨 Codex 啟動 opencodex", + "dash.codexAutoStartHint": "允許已安裝的 launcher shim 執行 ocx ensure。此設定不會安裝重新啟動保護;請在啟動安全中檢查實際狀態。", ++ "dash.codexDesktopAuthless": "無需登入即可開啟 Codex", ++ "dash.codexDesktopAuthlessHint": "預設關閉。為符合條件的本機連線略過獨立的 Desktop 登入。仍需上游供應商憑證。變更後請重新啟動 Codex。依賴帳戶的 Desktop 功能可能無法使用。", + "dash.searchModel": "搜尋附屬模型", + "dash.searchModelHint": "用於非 OpenAI 路由模型的 web_search 的模型。需要 ChatGPT 登入。", + "dash.searchReasoning": "搜尋推理強度", +diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts +index b10c48688..994691442 100644 +--- a/gui/src/i18n/zh.ts ++++ b/gui/src/i18n/zh.ts +@@ -303,6 +303,8 @@ export const zh: Record = { + "models.staleBanner": "Codex 显示的模型列表比当前目录旧。重启 Codex 即可重新读取。", + "dash.codexAutoStart": "随 Codex 启动 opencodex", + "dash.codexAutoStartHint": "允许已安装的 launcher shim 运行 ocx ensure。此设置不会安装重启保护;请在启动安全中检查实际状态。", ++ "dash.codexDesktopAuthless": "无需登录即可打开 Codex", ++ "dash.codexDesktopAuthlessHint": "默认关闭。为符合条件的本地连接跳过单独的 Desktop 登录。仍需上游提供商凭据。更改后请重启 Codex。依赖账户的 Desktop 功能可能不可用。", + "dash.searchModel": "搜索附属模型", + "dash.searchModelHint": "用于非 OpenAI 路由模型的 web_search 的模型。需要 ChatGPT 登录。", + "dash.searchReasoning": "搜索推理强度", +diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx +index 8da531f97..6606c4f56 100644 +--- a/gui/src/pages/dashboard-overview-sections.tsx ++++ b/gui/src/pages/dashboard-overview-sections.tsx +@@ -163,7 +163,7 @@ export function DashboardInjectionPanel({ d }: { apiBase: string; d: Dash }) { + + export function DashboardMaintenancePanel({ d }: { d: Dash }) { + const { +- t, runSync, syncing, updateTriggerRef, openUpdateDialog, updateLoading, updateOpen, ++ t, runSync, syncing, settingsSaving, updateTriggerRef, openUpdateDialog, updateLoading, updateOpen, + syncResult, syncError, updateJob, reconnecting, clearSyncFeedback, + } = d; + const syncHoldsWarning = !!syncResult && ( +@@ -211,7 +211,7 @@ export function DashboardMaintenancePanel({ d }: { d: Dash }) { +
{t("dash.syncModelsHint")}
+ +
+- +
+ + ++
++
++
++
{t("dash.codexDesktopAuthless")}
++
{t("dash.codexDesktopAuthlessHint")}
++ {settings?.catalogRefreshPending &&
{t("codexAuth.catalogRefreshPending")}
} ++
++ ++
++
++ +
+ {/* Both sidecar cards wear the DashboardInjectionPanel shell: the PANEL is + the flex row, copy left, controls right. */} +diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts +index 0793a7def..d24051028 100644 +--- a/gui/src/pages/dashboard-shared.ts ++++ b/gui/src/pages/dashboard-shared.ts +@@ -48,6 +48,8 @@ export interface ProviderInfo { name: string; adapter: string; baseUrl: string; + export interface ModelInfo { id: string; provider: string; namespaced: string; owned_by?: string; reasoningEfforts?: string[] } + export interface SettingsData { + codexAutoStart: boolean; ++ codexDesktopAuthless?: boolean; ++ catalogRefreshPending?: boolean; + /** Whether a login may open a browser on the machine running the proxy. */ + oauthOpenBrowser?: boolean; + port: number; +diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts +index 6f84950ce..6da776ea1 100644 +--- a/gui/src/pages/use-dashboard-data.ts ++++ b/gui/src/pages/use-dashboard-data.ts +@@ -607,23 +607,24 @@ export function useDashboardData(apiBase: string) { + finally { setInjectionSaving(false); } + }; + +- const toggleCodexAutoStart = async () => { +- if (!settings || settingsSaving) return; +- const next = !settings.codexAutoStart; ++ const toggleCodexSetting = async (key: "codexAutoStart" | "codexDesktopAuthless") => { ++ if (!settings || settingsSaving || syncing) return; ++ const next = !(settings[key] ?? (key === "codexAutoStart")); + setSettingsSaving(true); + settingsMutationInFlightRef.current = true; +- setSettings({ ...settings, codexAutoStart: next }); ++ setSettings({ ...settings, [key]: next }); + try { + const res = await fetch(`${apiBase}/api/settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, +- body: JSON.stringify({ codexAutoStart: next }), ++ body: JSON.stringify({ [key]: next }), + }); +- const data = await requireJson<{ codexAutoStart: boolean; startupHealth?: SettingsData["startupHealth"] }>(res, "save failed"); ++ const data = await requireJson(res, "save failed"); + settingsMutationEpochRef.current += 1; +- setSettings(prev => prev ? { ...prev, codexAutoStart: data.codexAutoStart, startupHealth: data.startupHealth ?? prev.startupHealth } : prev); ++ setSettings(prev => prev ? { ...prev, [key]: data[key], catalogRefreshPending: key === "codexDesktopAuthless" ? data.catalogRefreshPending : prev.catalogRefreshPending, startupHealth: data.startupHealth ?? prev.startupHealth } : prev); ++ if (key === "codexDesktopAuthless") await runSync(); + } catch { +- setSettings(prev => prev ? { ...prev, codexAutoStart: !next } : prev); ++ setSettings(prev => prev ? { ...prev, [key]: !next } : prev); + setError(true); + } finally { + settingsMutationInFlightRef.current = false; +@@ -631,6 +632,9 @@ export function useDashboardData(apiBase: string) { + } + }; + ++ const toggleCodexAutoStart = () => toggleCodexSetting("codexAutoStart"); ++ const toggleCodexDesktopAuthless = () => toggleCodexSetting("codexDesktopAuthless"); ++ + // Clears the sync result/error in this hook. The dashboard toast owns its own dismissal + // timer but must publish the dismissal here: syncResult/syncError live above the dashboard + // tabs, so a component-local flag alone would let a stale result remount as a fresh toast +@@ -649,6 +653,7 @@ export function useDashboardData(apiBase: string) { + const res = await fetch(`${apiBase}/api/sync`, { method: "POST" }); + const data = await requireJson(res, "sync failed"); + setSyncResult(data); ++ setSettings(prev => prev ? { ...prev, catalogRefreshPending: false } : prev); + if (data.projectConfigGrouped) setProjectConfigWarnings(data.projectConfigGrouped); + } catch (err) { + setSyncError(err instanceof Error ? err.message : String(err)); +@@ -789,7 +794,7 @@ export function useDashboardData(apiBase: string) { + effortCapHelpTriggerRef, updateTriggerRef, maHelpTriggerRef, shadowCallHelpTriggerRef, + effortCapHelpDialogRef, updateDialogRef, maHelpDialogRef, shadowCallHelpDialogRef, + filteredGroups, sidecarModels, visionModels, +- saveSidecar, saveShadowCall, switchMaMode, toggleCodexAutoStart, runSync, clearSyncFeedback, ++ saveSidecar, saveShadowCall, switchMaMode, toggleCodexAutoStart, toggleCodexDesktopAuthless, runSync, clearSyncFeedback, + fetchUpdateCheck, closeUpdateDialog, openUpdateDialog, changeUpdateChannel, runUpdate, + }; + } +diff --git a/gui/tests/vision-sidecar-dashboard.test.tsx b/gui/tests/vision-sidecar-dashboard.test.tsx +index dc762de58..994a40912 100644 +--- a/gui/tests/vision-sidecar-dashboard.test.tsx ++++ b/gui/tests/vision-sidecar-dashboard.test.tsx +@@ -12,7 +12,7 @@ import { LanguageProvider } from "../src/i18n/provider"; + import { DashboardSidecarPanels } from "../src/pages/dashboard-overview-sections"; + import type { SidecarData, SidecarPatch } from "../src/pages/dashboard-shared"; + import { mergeSidecarSetting } from "../src/pages/dashboard-shared"; +-import type { useDashboardData } from "../src/pages/use-dashboard-data"; ++import { useDashboardData } from "../src/pages/use-dashboard-data"; + + const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; + let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; +@@ -382,4 +382,79 @@ test("model and reasoning saves still omit enabled, limit, and timeout", async ( + expect(patches).toHaveLength(2); + expect(patches[1]).toEqual({ vision: { reasoning: "high" } }); + assertVisionControlFieldsOmitted(patches[1]!); +-}); +\ No newline at end of file ++}); ++ ++test("Desktop login switch defaults off, preserves explicit opt-in, and disables while saving", async () => { ++ const { d } = harness(); ++ let clicks = 0; ++ d.toggleCodexDesktopAuthless = async () => { clicks += 1; }; ++ d.settings = { codexAutoStart: true, port: 10100, hostname: "127.0.0.1" }; ++ await mount(d); ++ const toggle = () => host.querySelector(`button[aria-label="${en["dash.codexDesktopAuthless"]}"]`)!; ++ expect(toggle().getAttribute("aria-pressed")).toBe("false"); ++ d.settings.codexDesktopAuthless = true; ++ await mount(d); ++ expect(toggle().getAttribute("aria-pressed")).toBe("true"); ++ await act(async () => { toggle().click(); }); ++ expect(clicks).toBe(1); ++ d.settings.codexDesktopAuthless = false; ++ d.settings.catalogRefreshPending = true; ++ d.settingsSaving = true; ++ await mount(d); ++ expect(toggle().getAttribute("aria-pressed")).toBe("false"); ++ expect(toggle().disabled).toBe(true); ++ expect(host.textContent).toContain(en["codexAuth.catalogRefreshPending"]); ++}); ++ ++ ++test.each([undefined, false, true])("Desktop login preference %s persists before full sync; sync failure keeps the saved preference", async (initial) => { ++ const originalFetch = globalThis.fetch; ++ const writes: Array<{ path: string; body: unknown }> = []; ++ let latest: Dash | undefined; ++ let saved = initial; ++ const apiBase = `/authless-test-${String(initial)}`; ++ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { ++ const path = String(input); ++ if (init?.method === "PUT") { ++ const body = JSON.parse(String(init.body)); ++ writes.push({ path, body }); ++ if (body.codexDesktopAuthless !== undefined) { ++ saved = body.codexDesktopAuthless; ++ return Response.json({ codexDesktopAuthless: saved, catalogRefreshPending: true }); ++ } ++ return Response.json({ codexAutoStart: body.codexAutoStart, catalogRefreshPending: false }); ++ } ++ if (path.endsWith("/api/sync")) { ++ writes.push({ path, body: null }); ++ return Response.json({ error: "sync unavailable" }, { status: 503 }); ++ } ++ if (path.endsWith("/api/settings")) { ++ return Response.json({ codexAutoStart: true, codexDesktopAuthless: saved, port: 10100, hostname: "127.0.0.1" }); ++ } ++ return Response.json({}, { status: 503 }); ++ }) as typeof fetch; ++ function Harness() { latest = useDashboardData(apiBase); return null; } ++ try { ++ const { createRoot } = await import("react-dom/client"); ++ await act(async () => { ++ root = createRoot(host); ++ root.render(); ++ }); ++ expect(latest?.settings?.codexDesktopAuthless).toBe(initial); ++ await act(async () => { await latest!.toggleCodexDesktopAuthless(); }); ++ expect(writes).toEqual([ ++ { path: `${apiBase}/api/settings`, body: { codexDesktopAuthless: !initial } }, ++ { path: `${apiBase}/api/sync`, body: null }, ++ ]); ++ expect(latest?.settings?.codexDesktopAuthless).toBe(!initial); ++ expect(latest?.syncError).toBe("sync unavailable"); ++ expect(latest?.settings?.catalogRefreshPending).toBe(true); ++ await act(async () => { await latest!.toggleCodexAutoStart(); }); ++ expect(latest?.settings?.codexAutoStart).toBe(false); ++ expect(latest?.settings?.catalogRefreshPending).toBe(true); ++ } finally { ++ await act(async () => { root?.unmount(); }); ++ root = null; ++ globalThis.fetch = originalFetch; ++ } ++}); +diff --git a/tests/codex-integration/codex-inject.test.ts b/tests/codex-integration/codex-inject.test.ts +index 84ac5f67b..b6be3c2f6 100644 +--- a/tests/codex-integration/codex-inject.test.ts ++++ b/tests/codex-integration/codex-inject.test.ts +@@ -31,8 +31,8 @@ describe("Codex config injection", () => { + }); + + describe("authless Codex Desktop opt-in (#1107)", () => { +- test("default target on loopback stays Design B and byte-identical", () => { +- const target = standaloneCodexRoutingTarget(10100, {}); ++ test.each([undefined, false])("disabled preference %s on loopback stays Design B and byte-identical", (codexDesktopAuthless) => { ++ const target = standaloneCodexRoutingTarget(10100, { codexDesktopAuthless }); + expect(target.desktopAuthless).toBeUndefined(); + expect(buildProfileFile(target, null)).toBe(buildProfileFile(10100, null)); + expect(buildProviderTableBlock(target)).toContain("requires_openai_auth = true"); + +``` + +Audit amendment: clear catalogRefreshPending only if sync status is affirmative success, not HTTP 200 skipped. Add skipped/no-write regression. diff --git a/devlog/_plan/260907_lane_c/050_fallback.md b/devlog/_plan/260907_lane_c/050_fallback.md new file mode 100644 index 0000000000..6bf165a96c --- /dev/null +++ b/devlog/_plan/260907_lane_c/050_fallback.md @@ -0,0 +1,368 @@ +# 3252 implementation contract + +Carry source commits with -x. Preserve configured fallback models absent from availability. Add focused GUI tests for add/remove/reorder/save and unavailable model round-trip. Reuse existing /api/v2 (enabled, multiAgentMode, keepNativeChatGptOnV1) and report recovery enabled/eligibility as unknown when the server does not expose it, never fabricate recovery settings state for contextual native-parent/routed-child V2 guidance. Never infer all workflows are native; warn conditionally, show disabled/eligible/experimental/unknown state truthfully, link issue 92. No roster-reuse switch. Update all locales and codex-integration docs; actual UI screenshot. New PR body is valid Markdown, removes unsupported roster-switch claims. + +Validation: local tests/typecheck/build/install NOT RUN by instruction. Read diff and source; top remote CI exercises changed test paths. Each conditional branch listed above is exercised by controlled fixtures; screenshot inspects GUI state. No new enforcement layer; existing API guards remain authoritative. + +## Public source diff (MODIFY/NEW paths) + +```diff +diff --git a/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx b/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx +index 46c0447a7..7c3b0e942 100644 +--- a/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx ++++ b/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx +@@ -28,6 +28,13 @@ export interface SubagentDelegationSectionProps { + onUltraModeSave: (patch: UltraModePatch) => void; + ultraLoadFailed: boolean; + onUltraModeRetry: () => void; ++ fallback: string[]; ++ fallbackPollMs: number; ++ fallbackBusy: boolean; ++ availableModels: string[]; ++ onFallbackChange: (models: string[]) => void; ++ onFallbackPollMsChange: (pollMs: number) => void; ++ onFallbackSave: () => void; + } + + export default function SubagentDelegationSection({ +@@ -44,6 +51,7 @@ export default function SubagentDelegationSection({ + onUltraModeSave, + ultraLoadFailed, + onUltraModeRetry, ++ fallback, fallbackPollMs, fallbackBusy, availableModels, onFallbackChange, onFallbackPollMsChange, onFallbackSave, + }: SubagentDelegationSectionProps) { + const t = useT(); + // A present empty/whitespace hint is an upstream override that suppresses the +@@ -97,6 +105,31 @@ export default function SubagentDelegationSection({ +
+ + ++
++
++
{t("sub.fallbackLabel")}
++
{t("sub.fallbackHint")}
++
++
++ {fallback.map((modelName, index) => ( ++
++ {index + 1}. {modelName} ++ ++ ++ ++
++ ))} ++ ++ ++ ++
++
++ +
+
+
{t("dash.syncCodexSubagentDefaults")}
+diff --git a/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx b/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx +index a22bd2a30..30b722b2b 100644 +--- a/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx ++++ b/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx +@@ -37,6 +37,12 @@ export interface SubagentsWorkspaceProps { + onToggle: (m: string) => void; + onMove: (i: number, dir: -1 | 1) => void; + onSave: () => void; ++ fallback: string[]; ++ fallbackPollMs: number; ++ fallbackBusy: boolean; ++ onFallbackChange: (models: string[]) => void; ++ onFallbackPollMsChange: (pollMs: number) => void; ++ onFallbackSave: () => void; + delegation: { + model: string; + effort: string; +@@ -63,6 +69,7 @@ export default function SubagentsWorkspace({ + onToggle, + onMove, + onSave, ++ fallback, fallbackPollMs, fallbackBusy, onFallbackChange, onFallbackPollMsChange, onFallbackSave, + delegation, + }: SubagentsWorkspaceProps) { + const t = useT(); +@@ -237,6 +244,13 @@ export default function SubagentsWorkspace({ + onUltraModeSave={delegation.onUltraModeSave} + ultraLoadFailed={delegation.ultraLoadFailed} + onUltraModeRetry={delegation.onUltraModeRetry} ++ fallback={fallback} ++ fallbackPollMs={fallbackPollMs} ++ fallbackBusy={fallbackBusy} ++ availableModels={available} ++ onFallbackChange={onFallbackChange} ++ onFallbackPollMsChange={onFallbackPollMsChange} ++ onFallbackSave={onFallbackSave} + /> + +
+diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts +index 429379396..2bb0b10c1 100644 +--- a/gui/src/i18n/de.ts ++++ b/gui/src/i18n/de.ts +@@ -672,6 +672,12 @@ export const de: Record = { + "sub.ultraModeLoadFail": "Ultra-Modus-Einstellungen konnten nicht geladen werden — läuft der Proxy?", + "sub.ultraModeSaveFail": "Ultra-Modus-Einstellungen konnten nicht gespeichert werden", + "sub.ultraModeSaved": "Ultra-Modus gespeichert. Gilt für neue Codex-Sitzungen.", ++ "sub.fallbackLabel": "Fallback-Kette für Sub-Agenten", ++ "sub.fallbackHint": "Geordnete Modelle, die versucht werden, wenn ein Sub-Agent-Modell nicht verfügbar ist oder fehlschlägt.", ++ "sub.fallbackAdd": "Fallback-Modell hinzufügen…", ++ "sub.fallbackPoll": "Intervall der Verfügbarkeitsprüfung", ++ "sub.fallbackSaved": "Fallback-Einstellungen für Sub-Agenten gespeichert.", ++ "sub.fallbackSaveFailed": "Fallback-Einstellungen konnten nicht gespeichert werden", + "logs.title": "Anfrage-Protokolle", + "logs.tabLogs": "Protokolle", + "logs.tabDebug": "Diagnose", +diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts +index 9cbf8699f..e1346616a 100644 +--- a/gui/src/i18n/en.ts ++++ b/gui/src/i18n/en.ts +@@ -315,6 +315,12 @@ export const en = { + "dash.visionTimeout": "Timeout", + "dash.visionTimeoutInvalid": "Enter an integer from {min} to {max} milliseconds.", + "dash.visionAdvancedPopover": "Advanced vision settings", ++ "sub.fallbackLabel": "Sub-agent fallback chain", ++ "sub.fallbackHint": "Ordered models tried when a sub-agent model is unavailable or fails.", ++ "sub.fallbackAdd": "Add fallback model…", ++ "sub.fallbackPoll": "Availability check interval", ++ "sub.fallbackSaved": "Sub-agent fallback settings saved.", ++ "sub.fallbackSaveFailed": "Failed to save fallback settings", + "dash.shadowCallIntercept": "Shadow Call Intercept", + "dash.shadowCallInterceptHint": "Intercepts Codex App's background helper calls ({models}) for title generation and commit messages and redirects them to your chosen model.", + "dash.shadowCallWarning": "⚠ When enabled, ALL requests for {models} will be replaced with the selected model.", +diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts +index ec171e627..d5d29b0be 100644 +--- a/gui/src/i18n/fr.ts ++++ b/gui/src/i18n/fr.ts +@@ -305,6 +305,12 @@ export const fr: Record = { + "dash.visionTimeout": "Délai d’expiration", + "dash.visionTimeoutInvalid": "Saisissez un entier compris entre {min} et {max} millisecondes.", + "dash.visionAdvancedPopover": "Paramètres de vision avancés", ++ "sub.fallbackLabel": "Chaîne de secours des sous-agents", ++ "sub.fallbackHint": "Modèles essayés dans l’ordre lorsqu’un modèle de sous-agent est indisponible ou échoue.", ++ "sub.fallbackAdd": "Ajouter un modèle de secours…", ++ "sub.fallbackPoll": "Intervalle de vérification de disponibilité", ++ "sub.fallbackSaved": "Paramètres de secours des sous-agents enregistrés.", ++ "sub.fallbackSaveFailed": "Échec de l’enregistrement des paramètres de secours", + "dash.shadowCallIntercept": "Interception des appels fantômes", + "dash.shadowCallInterceptHint": "Intercepte les appels auxiliaires en arrière-plan de l’application Codex ({models}) pour générer les titres et les messages de commit, puis les redirige vers le modèle choisi.", + "dash.shadowCallWarning": "⚠ Lorsque cette option est activée, TOUTES les requêtes destinées à {models} sont remplacées par le modèle sélectionné.", +diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts +index c71bd7a04..747438bdc 100644 +--- a/gui/src/i18n/ja.ts ++++ b/gui/src/i18n/ja.ts +@@ -632,6 +632,12 @@ export const ja: Record = { + "sub.ultraModeLoadFail": "ウルトラモード設定を読み込めませんでした — プロキシは実行中ですか?", + "sub.ultraModeSaveFail": "ウルトラモード設定の保存に失敗しました", + "sub.ultraModeSaved": "ウルトラモードを保存しました。新しい Codex セッションから適用されます。", ++ "sub.fallbackLabel": "サブエージェントのフォールバックチェーン", ++ "sub.fallbackHint": "サブエージェントモデルが利用できないか失敗した場合に順番に試すモデルです。", ++ "sub.fallbackAdd": "フォールバックモデルを追加…", ++ "sub.fallbackPoll": "利用可能性チェック間隔", ++ "sub.fallbackSaved": "サブエージェントのフォールバック設定を保存しました。", ++ "sub.fallbackSaveFailed": "フォールバック設定の保存に失敗しました", + + // logs + "logs.title": "リクエストログ", +diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts +index 63ac30442..ecc0e4560 100644 +--- a/gui/src/i18n/ko.ts ++++ b/gui/src/i18n/ko.ts +@@ -689,6 +689,12 @@ export const ko: Record = { + "sub.ultraModeLoadFail": "울트라 모드 설정을 불러오지 못했습니다 — 프록시가 실행 중인가요?", + "sub.ultraModeSaveFail": "울트라 모드 설정 저장에 실패했습니다", + "sub.ultraModeSaved": "울트라 모드가 저장되었습니다. 새 Codex 세션부터 적용됩니다.", ++ "sub.fallbackLabel": "서브에이전트 폴백 체인", ++ "sub.fallbackHint": "서브에이전트 모델을 사용할 수 없거나 실패할 때 순서대로 시도할 모델입니다.", ++ "sub.fallbackAdd": "폴백 모델 추가…", ++ "sub.fallbackPoll": "가용성 확인 간격", ++ "sub.fallbackSaved": "서브에이전트 폴백 설정을 저장했습니다.", ++ "sub.fallbackSaveFailed": "폴백 설정을 저장하지 못했습니다", + + // logs + "logs.title": "요청 로그", +diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts +index 9f220ba2b..852eb4467 100644 +--- a/gui/src/i18n/ru.ts ++++ b/gui/src/i18n/ru.ts +@@ -687,6 +687,12 @@ export const ru: Record = { + "sub.ultraModeLoadFail": "Не удалось загрузить настройки ультра-режима — работает ли прокси?", + "sub.ultraModeSaveFail": "Не удалось сохранить настройки ультра-режима", + "sub.ultraModeSaved": "Ультра-режим сохранён. Применяется к новым сеансам Codex.", ++ "sub.fallbackLabel": "Цепочка резервных моделей субагента", ++ "sub.fallbackHint": "Модели, которые последовательно пробуются, если модель субагента недоступна или завершается ошибкой.", ++ "sub.fallbackAdd": "Добавить резервную модель…", ++ "sub.fallbackPoll": "Интервал проверки доступности", ++ "sub.fallbackSaved": "Настройки резервных моделей субагента сохранены.", ++ "sub.fallbackSaveFailed": "Не удалось сохранить настройки резервных моделей", + + // logs + "logs.title": "Журнал запросов", +diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts +index aee152cd3..71d9e7313 100644 +--- a/gui/src/i18n/tr.ts ++++ b/gui/src/i18n/tr.ts +@@ -694,6 +694,12 @@ export const tr: Record = { + "sub.ultraModeLoadFail": "Ultra modu ayarları yüklenemedi — proxy çalışıyor mu?", + "sub.ultraModeSaveFail": "Ultra modu ayarları kaydedilemedi", + "sub.ultraModeSaved": "Ultra modu kaydedildi. Yeni Codex oturumlarına uygulanır.", ++ "sub.fallbackLabel": "Alt ajan yedek zinciri", ++ "sub.fallbackHint": "Alt ajan modeli kullanılamadığında veya başarısız olduğunda sırayla denenecek modeller.", ++ "sub.fallbackAdd": "Yedek model ekle…", ++ "sub.fallbackPoll": "Kullanılabilirlik kontrol aralığı", ++ "sub.fallbackSaved": "Alt ajan yedek ayarları kaydedildi.", ++ "sub.fallbackSaveFailed": "Yedek ayarlar kaydedilemedi", + + // logs + "logs.title": "İstek Günlükleri", +diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts +index 39c9e2f0b..50659c2e6 100644 +--- a/gui/src/i18n/zh-TW.ts ++++ b/gui/src/i18n/zh-TW.ts +@@ -541,6 +541,12 @@ export const zhTW: Record = { + "sub.ultraModeLoadFail": "無法載入超級模式設定 — 代理是否在執行?", + "sub.ultraModeSaveFail": "儲存超級模式設定失敗", + "sub.ultraModeSaved": "超級模式已儲存。適用於新的 Codex 會話。", ++ "sub.fallbackLabel": "子代理備援鏈", ++ "sub.fallbackHint": "子代理模型無法使用或失敗時,依序嘗試的模型。", ++ "sub.fallbackAdd": "新增備援模型…", ++ "sub.fallbackPoll": "可用性檢查間隔", ++ "sub.fallbackSaved": "子代理備援設定已儲存。", ++ "sub.fallbackSaveFailed": "備援設定儲存失敗", + "logs.title": "請求日誌", + "logs.tabLogs": "日誌", + "logs.tabDebug": "除錯", +diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts +index 1ba4cabfa..ded94d699 100644 +--- a/gui/src/i18n/zh.ts ++++ b/gui/src/i18n/zh.ts +@@ -682,6 +682,12 @@ export const zh: Record = { + "sub.ultraModeLoadFail": "无法加载超级模式设置 — 代理是否在运行?", + "sub.ultraModeSaveFail": "保存超级模式设置失败", + "sub.ultraModeSaved": "超级模式已保存。适用于新的 Codex 会话。", ++ "sub.fallbackLabel": "子代理回退链", ++ "sub.fallbackHint": "子代理模型不可用或失败时按顺序尝试的模型。", ++ "sub.fallbackAdd": "添加回退模型…", ++ "sub.fallbackPoll": "可用性检查间隔", ++ "sub.fallbackSaved": "子代理回退设置已保存。", ++ "sub.fallbackSaveFailed": "保存回退设置失败", + + // logs + "logs.title": "请求日志", +diff --git a/gui/src/pages/Subagents.tsx b/gui/src/pages/Subagents.tsx +index 6b54d39ff..299c9306f 100644 +--- a/gui/src/pages/Subagents.tsx ++++ b/gui/src/pages/Subagents.tsx +@@ -8,7 +8,7 @@ import { useDataSurface } from "../data-surface"; + import { DataSurfaceSkeleton } from "../components/data-surface"; + import { useSubagentDelegation, type UltraModePatch, type UltraModeState } from "./use-subagent-delegation"; + +-type CachedSubagents = { available: string[]; chosen: string[] }; ++type CachedSubagents = { available: string[]; chosen: string[]; fallback: string[]; pollMs: number }; + + function seedSubagents(cacheKey: string): CachedSubagents | null { + return readSessionListCache(cacheKey); +@@ -19,6 +19,9 @@ export default function Subagents({ apiBase }: { apiBase: string }) { + const cacheKey = `ocx.subagents.v1:${apiBase}`; + const cached = seedSubagents(cacheKey); + const [chosen, setChosen] = useState(() => cached?.chosen ?? []); ++ const [fallback, setFallback] = useState(() => cached?.fallback ?? []); ++ const [fallbackPollMs, setFallbackPollMs] = useState(() => cached?.pollMs ?? 60000); ++ const [fallbackBusy, setFallbackBusy] = useState(false); + const [status, setStatus] = useState(""); + const [ok, setOk] = useState(false); + const [busy, setBusy] = useState(false); +@@ -117,16 +120,24 @@ export default function Subagents({ apiBase }: { apiBase: string }) { + const loadSubagents = useCallback(async (signal?: AbortSignal): Promise => { + // The resource layer's deadline abort must reach the wire — a signal dropped + // here is a store that can only settle by race timeout. +- const res = await fetch(`${apiBase}/api/subagent-models`, { signal }); +- const response = await readJsonOrThrow<{ available?: string[]; chosen?: string[] }>(res, t("sub.loadFail")); +- if (!response) throw new Error(t("sub.loadFail")); +- const available = response.available ?? []; ++ const [rosterRes, fallbackRes] = await Promise.all([ ++ fetch(`${apiBase}/api/subagent-models`, { signal }), ++ fetch(`${apiBase}/api/subagent-model-fallback`, { signal }), ++ ]); ++ const response = await readJsonOrThrow<{ available?: string[]; chosen?: string[] }>(rosterRes, t("sub.loadFail")); ++ const fallbackResponse = await readJsonOrThrow<{ available?: string[]; models?: string[]; pollMs?: number }>(fallbackRes, t("sub.loadFail")); ++ if (!response || !fallbackResponse) throw new Error(t("sub.loadFail")); ++ const available = response.available ?? fallbackResponse.available ?? []; + const availableSet = new Set(available); + const next = { + available, + chosen: (response.chosen ?? []).filter(model => availableSet.has(model)), ++ fallback: (fallbackResponse.models ?? []).filter(model => availableSet.has(model)), ++ pollMs: fallbackResponse.pollMs ?? 60000, + }; + setChosen(next.chosen); ++ setFallback(next.fallback); ++ setFallbackPollMs(next.pollMs); + writeSessionListCache(cacheKey, next); + return next; + }, [apiBase, cacheKey, t]); +@@ -174,7 +185,7 @@ export default function Subagents({ apiBase }: { apiBase: string }) { + const d = await readJsonOrThrow<{ applied?: string[] }>(r, t("sub.saveFailed")); + const applied = d?.applied ?? chosen; + if (d?.applied) setChosen(d.applied); +- writeSessionListCache(cacheKey, { available, chosen: applied }); ++ writeSessionListCache(cacheKey, { available, chosen: applied, fallback, pollMs: fallbackPollMs }); + setOk(true); + setStatus(t("sub.saved", { n: applied.length, cmd: "ocx sync" })); + } catch (error) { +@@ -186,6 +197,28 @@ export default function Subagents({ apiBase }: { apiBase: string }) { + } + }; + ++ const saveFallback = async () => { ++ if (fallbackBusy) return; ++ setFallbackBusy(true); ++ try { ++ const r = await fetch(`${apiBase}/api/subagent-model-fallback`, { ++ method: "PUT", ++ headers: { "Content-Type": "application/json" }, ++ body: JSON.stringify({ models: fallback, pollMs: fallbackPollMs }), ++ }); ++ const d = await readJsonOrThrow<{ models?: string[]; pollMs?: number }>(r, t("sub.fallbackSaveFailed")); ++ if (d?.models) setFallback(d.models); ++ if (d?.pollMs) setFallbackPollMs(d.pollMs); ++ setOk(true); ++ setStatus(t("sub.fallbackSaved")); ++ } catch (error) { ++ setOk(false); ++ setStatus(error instanceof Error && error.message ? error.message : t("sub.networkError")); ++ } finally { ++ setFallbackBusy(false); ++ } ++ }; ++ + // The skeleton owns the live region while this resource has no content yet. + if (state.showSkeleton && !snapshot) { + return ; +@@ -214,7 +247,13 @@ export default function Subagents({ apiBase }: { apiBase: string }) { + busy={busy} + onToggle={toggle} + onMove={move} +- onSave={() => { void save(); }} ++ onSave={() => { void save(); }} ++ fallback={fallback} ++ fallbackPollMs={fallbackPollMs} ++ fallbackBusy={fallbackBusy} ++ onFallbackChange={setFallback} ++ onFallbackPollMsChange={setFallbackPollMs} ++ onFallbackSave={() => { void saveFallback(); }} + delegation={{ + model: delegation.model, + effort: delegation.effort, + +``` + +Audit amendment: cache server-confirmed fallback values after fallback Save; roster Save preserves committed fallback snapshot, never draft. Add independent-save and remount regressions. Existing dashboard density, CSS tokens, Select and icon library retained; no concept art needed for utility editor. diff --git a/src/web-search/anthropic-executor.ts b/src/web-search/anthropic-executor.ts index 1eb206afa8..cd3893900c 100644 --- a/src/web-search/anthropic-executor.ts +++ b/src/web-search/anthropic-executor.ts @@ -5,7 +5,11 @@ import { CLAUDE_CODE_HEADERS, claudeCodeSessionId } from "../adapters/client-fin import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort"; import { sidecarEnter } from "../lib/sidecar-tracker"; import { applyUpstreamRecoveryInit, fetchWithResetRetry } from "../lib/upstream-retry"; -import type { WebSearchSource } from "./parse"; +import { + MAX_SIDECAR_RESPONSE_BYTES, + cancelReaderWithoutWaiting, + type WebSearchSource, +} from "./parse"; import { BASE_INSTRUCTION, IMAGE_INSTRUCTION, type SidecarOutcome, type SidecarSettings } from "./executor"; /** Hardcoded per-turn search bound handed to the server tool (mirrors the loop's maxSearches intent). */ @@ -17,6 +21,33 @@ function isRec(v: unknown): v is Record { return !!v && typeof v === "object" && !Array.isArray(v); } +/** Read at most `MAX_SIDECAR_RESPONSE_BYTES` of an untrusted upstream body, then stop reading. */ +async function readBoundedText(res: Response): Promise { + if (!res.body) return ""; + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let out = ""; + let seen = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + const remaining = MAX_SIDECAR_RESPONSE_BYTES - seen; + const accepted = value.byteLength <= remaining ? value : value.subarray(0, remaining); + seen += accepted.byteLength; + out += decoder.decode(accepted, { stream: true }); + if (seen >= MAX_SIDECAR_RESPONSE_BYTES) { + cancelReaderWithoutWaiting(reader, "sidecar error body byte limit reached"); + break; + } + } + out += decoder.decode(); + } catch { + /* a failed error-body read must not mask the HTTP status we are about to report */ + } + return out; +} + /** * Fold an Anthropic Messages SSE stream (a web_search_20250305 turn) into a WebSearchResult. * @@ -41,6 +72,7 @@ export async function parseAnthropicSidecarSSE(res: Response): Promise): void => { const type = typeof data.type === "string" ? data.type : ""; @@ -82,15 +114,27 @@ export async function parseAnthropicSidecarSSE(res: Response): Promise= MAX_SIDECAR_RESPONSE_BYTES) { + // Keep the frames already folded above, drop the unterminated tail, and do not wait on + // upstream teardown. + cancelReaderWithoutWaiting(reader, "sidecar response byte limit reached"); + buffer = ""; + break; + } } // Flush the decoder and process any final unterminated frame (a stream that ends without \n\n). buffer = (buffer + decoder.decode()).replace(/\r\n/g, "\n"); @@ -177,7 +221,9 @@ export async function runAnthropicWebSearch( // (found investigating #1419). const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); if (!res.ok) { - const t = await res.text().catch(() => ""); + // Untrusted upstream error bodies are only used for an auth-failure message, so read a + // bounded prefix instead of buffering an arbitrarily large response. + const t = await readBoundedText(res); detachBodyGuard(); console.warn(`[web-search] anthropic sidecar HTTP ${res.status} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`); if (res.status === 401) { diff --git a/src/web-search/parse.ts b/src/web-search/parse.ts index 757c309f3e..7ba5d2607c 100644 --- a/src/web-search/parse.ts +++ b/src/web-search/parse.ts @@ -193,7 +193,7 @@ function fromOutputArray(output: OutputItem[], seen: Set): WebSearchResu return { text, sources }; } -function cancelReaderWithoutWaiting( +export function cancelReaderWithoutWaiting( reader: ReadableStreamDefaultReader, reason: string, ): void { diff --git a/tests/web-search/web-search-anthropic.test.ts b/tests/web-search/web-search-anthropic.test.ts index f5b7f1df26..980eacddaa 100644 --- a/tests/web-search/web-search-anthropic.test.ts +++ b/tests/web-search/web-search-anthropic.test.ts @@ -130,6 +130,27 @@ describe("parseAnthropicSidecarSSE", () => { expect(out.error).toBeDefined(); }); + test("an unterminated frame cannot buffer the stream without bound", async () => { + // A sidecar that never emits a frame separator: without a cap the parser would accumulate + // the whole stream in memory before it could fold anything. + let produced = 0; + let cancelled = false; + const chunk = new TextEncoder().encode(`data: {"filler":"${"x".repeat(64 * 1024)}"}`); + const body = new ReadableStream({ + pull(c) { + if (produced > 8 * 1024 * 1024) { c.close(); return; } + produced += chunk.byteLength; + c.enqueue(chunk); + }, + cancel() { cancelled = true; }, + }); + const out = await parseAnthropicSidecarSSE(new Response(body, { status: 200 })); + expect(cancelled).toBe(true); + // The cap stops the read long before the producer would have finished on its own. + expect(produced).toBeLessThan(1024 * 1024); + expect(out.text).toBe(""); + }); + test("empty results (content:[]) with answer text is a success, not an error", async () => { const res = sseResponse([ { type: "content_block_start", index: 0, content_block: { type: "web_search_tool_result", tool_use_id: "srvtoolu_3", content: [] } }, @@ -172,6 +193,26 @@ describe("parseAnthropicSidecarSSE", () => { }); }); +describe("Anthropic sidecar byte boundaries", () => { + test.each([64 * 1024, 80 * 1024])("preserves complete prefix frames at %i bytes without awaiting cancel", async (size) => { + const prefix = `data: ${JSON.stringify({ type: "content_block_delta", delta: { type: "text_delta", text: "prefix 한글" } })}\n\n`; + const tail = `data: ${JSON.stringify({ type: "content_block_delta", delta: { type: "text_delta", text: "discard" } })}`; + const encoder = new TextEncoder(); + // The final unterminated frame is syntactically valid exactly at the cap. + // EOF flush must not fold it after cancellation. + const body = prefix + ":" + "x".repeat(64 * 1024 - encoder.encode(prefix + "\n\n" + tail).length - 1) + "\n\n" + tail; + const bytes = encoder.encode(body + "z".repeat(size - 64 * 1024)); + let cancelled = false; + const res = new Response(new ReadableStream({ + start(controller) { controller.enqueue(bytes); }, + cancel() { cancelled = true; return new Promise(() => {}); }, + }, { highWaterMark: 0 })); + const out = await parseAnthropicSidecarSSE(res); + expect(cancelled).toBe(true); + expect(out).toEqual({ text: "prefix 한글", sources: [] }); + }); +}); + describe("runAnthropicWebSearch request shape", () => { const originalFetch = globalThis.fetch; afterEach(() => { @@ -179,6 +220,21 @@ describe("runAnthropicWebSearch request shape", () => { oauthAccessError = undefined; }); + test.each([401, 503])("bounds HTTP %i error bodies and never awaits non-settling cancellation", async (status) => { + let reads = 0; + let cancelled = false; + globalThis.fetch = (async () => new Response(new ReadableStream({ + pull(controller) { reads += 1; controller.enqueue(new Uint8Array(4096).fill(120)); }, + cancel() { cancelled = true; return new Promise(() => {}); }, + }, { highWaterMark: 0 }), { status })) as typeof fetch; + const out = await runAnthropicWebSearch("bounded fixture", "anthropic", anthropicProvider, + { model: "claude-sonnet-5", reasoning: "low", timeoutMs: 5000, describeImages: false }); + expect(reads).toBe(16); + expect(cancelled).toBe(true); + expect(out.error).toBe(status === 401 + ? `anthropic sidecar auth failed: ${PUBLIC_OAUTH_ERROR}` : "sidecar HTTP 503"); + }); + test("projects OAuth, upstream-auth, and transport failures onto safe public errors", async () => { oauthAccessError = new Error(`credential read failed at ${AUTH_ERROR_CANARY}`); const credentialFailure = await runAnthropicWebSearch(