From 2b158e9bada538a6f3779ae47154ba150554218b Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:50:46 +0900 Subject: [PATCH 1/4] fix(vision): bound the Anthropic vision sidecar SSE and error bodies [skip ci] (cherry picked from commit 55b009bdec123a79924a9c40c97f94bf49e62591) Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- src/vision/anthropic-describe.ts | 48 +++++++++++++++++++++++++-- tests/vision/vision-anthropic.test.ts | 21 ++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/vision/anthropic-describe.ts b/src/vision/anthropic-describe.ts index 4f41017ef5..280096f033 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 086e5df7bb..05df35662e 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(""); From 8eaa5641902b84bec6c97a94de6c2fe6d810c31d Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 18:24:12 +0900 Subject: [PATCH 2/4] test(vision): pin capped descriptions and non-settling error-body cancel [skip ci] Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- tests/vision/vision-anthropic.test.ts | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/vision/vision-anthropic.test.ts b/tests/vision/vision-anthropic.test.ts index 05df35662e..0c0ef3c095 100644 --- a/tests/vision/vision-anthropic.test.ts +++ b/tests/vision/vision-anthropic.test.ts @@ -73,6 +73,34 @@ describe("Anthropic vision executor", () => { oauthAccessError = undefined; }); + test.each([64 * 1024, 80 * 1024])("keeps only complete partial description frames at %i bytes without waiting for cancel", async (size) => { + const prefix = `data: ${JSON.stringify({ type: "content_block_delta", delta: { type: "text_delta", text: "partial 한글" } })}\n\n`; + const tail = `data: ${JSON.stringify({ type: "content_block_delta", delta: { type: "text_delta", text: "discard" } })}`; + const encoder = new TextEncoder(); + const body = prefix + ":" + "x".repeat(64 * 1024 - encoder.encode(prefix + "\n\n" + tail).length - 1) + "\n\n" + tail; + let cancelled = false; + const out = await parseAnthropicVisionSSE(new Response(new ReadableStream({ + start(controller) { controller.enqueue(encoder.encode(body + "z".repeat(size - 64 * 1024))); }, + cancel() { cancelled = true; return new Promise(() => {}); }, + }, { highWaterMark: 0 }))); + expect(cancelled).toBe(true); + expect(out).toEqual({ text: "partial 한글" }); + }); + + test.each([401, 503])("bounds HTTP %i error bodies even when cancellation never settles", 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 describeImageAnthropic(DATA_IMAGE, "high", "", "anthropic-vision-test", anthropicProvider, settings); + expect(reads).toBe(16); + expect(cancelled).toBe(true); + expect(out.error).toBe(status === 401 + ? `anthropic vision sidecar auth failed: ${PUBLIC_OAUTH_ERROR}` : "anthropic vision sidecar HTTP 503"); + }); + test("projects OAuth, upstream-auth, and transport failures onto safe replacement errors", async () => { oauthAccessError = new Error(`credential read failed at ${AUTH_ERROR_CANARY}`); const credentialFailure = await describeImageAnthropic( From 9d8d11abdee0151c2a283ff4c3ace728a02a0455 Mon Sep 17 00:00:00 2001 From: x3M3x <98298256+x3M3x@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:24:13 +0900 Subject: [PATCH 3/4] fix(service): carry startup-health cache portion of #3863 [skip ci] Path-filtered source commit: 960621616c439e69b967981c290f2377ba9465fa. Config-route wiring excluded under lane ownership. Co-authored-by: x3M3x <98298256+x3M3x@users.noreply.github.com> (cherry picked from commit 197bf2e2bff362b9a135389741f1acf91670ded0) --- src/server/startup-health-cache.ts | 16 +++++++++++ tests/service/autostart-health.test.ts | 39 +++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/server/startup-health-cache.ts b/src/server/startup-health-cache.ts index 70380eb4ed..571d81b549 100644 --- a/src/server/startup-health-cache.ts +++ b/src/server/startup-health-cache.ts @@ -50,6 +50,22 @@ export interface StartupHealthCacheDeps { ) => 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 639f1b34c3..48bb7b5395 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"; From 91b54b79bc780011031b00354621cc9fcaef360e Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 18:25:01 +0900 Subject: [PATCH 4/4] fix(service): preserve fresh health and contain detached probe failures [skip ci] Co-authored-by: x3M3x <98298256+x3M3x@users.noreply.github.com> --- src/server/startup-health-cache.ts | 26 ++++++---- tests/service/autostart-health.test.ts | 72 +++++++++++++++++++++----- 2 files changed, 75 insertions(+), 23 deletions(-) diff --git a/src/server/startup-health-cache.ts b/src/server/startup-health-cache.ts index 571d81b549..2c12e0bbc3 100644 --- a/src/server/startup-health-cache.ts +++ b/src/server/startup-health-cache.ts @@ -62,7 +62,8 @@ export function getStartupHealthSnapshot( deps: StartupHealthCacheDeps = {}, ): StartupHealth { const now = deps.now ?? Date.now; - if (!cached || now() - cached.timestamp >= CACHE_TTL_MS) refreshInBackground(config, deps); + if (cached && now() - cached.timestamp < CACHE_TTL_MS) return cached.value; + refreshInBackground(config, deps); return cached ? markStartupHealthDiagnosticStale(cached.value) : conservativeFallback(config); } @@ -150,15 +151,20 @@ function refreshInBackground( ): void { if (inflight) return; const startedGeneration = generation; - const probe = (deps.probe ?? runProbe)(config).then(value => { - if (startedGeneration === generation) { - cached = { timestamp: (deps.now ?? Date.now)(), value }; - } - return value; - }); - inflight = probe.finally(() => { - if (inflight === probe || startedGeneration === generation) inflight = null; - }); + const probe: Promise = Promise.resolve() + .then(() => (deps.probe ?? runProbe)(config)) + .then(value => { + if (startedGeneration === generation) { + cached = { timestamp: (deps.now ?? Date.now)(), value }; + } + return value; + }) + .catch(() => cached ? markStartupHealthDiagnosticStale(cached.value) : conservativeFallback(config)) + .finally(() => { + // An invalidated probe must never clear the newer generation's flight. + if (inflight === probe) inflight = null; + }); + inflight = probe; } /** Stale-while-revalidate: service-manager probes never hold open a model/UI request. */ diff --git a/tests/service/autostart-health.test.ts b/tests/service/autostart-health.test.ts index 48bb7b5395..213a118e2b 100644 --- a/tests/service/autostart-health.test.ts +++ b/tests/service/autostart-health.test.ts @@ -296,22 +296,68 @@ describe("Codex startup health", () => { invalidateStartupHealthCache(); }); - test("settings GET uses the non-blocking startup-health snapshot in production", async () => { + test("snapshot preserves fresh protection and returns expired protection before a controlled probe settles", async () => { invalidateStartupHealthCache(); - const url = new URL("http://localhost/api/settings"); + let now = 1_000; + const config = { codexAutoStart: true }; + const protectedHealth = deriveStartupHealth({ ...base, serviceInstalled: true, serviceViable: true, serviceEnabled: true, serviceRunning: true }); + await getCachedStartupHealth(config, { now: () => now, probe: async () => protectedHealth, waitForProbe: probe => probe }); + let calls = 0; + let release!: (value: typeof protectedHealth) => void; + const pending = new Promise(resolve => { release = resolve; }); + const deps = { now: () => now, probe: () => { calls += 1; return pending; }, waitForProbe: (probe: Promise) => probe }; + expect(getStartupHealthSnapshot(config, deps)).toBe(protectedHealth); + expect(calls).toBe(0); + now += 30_000; + const snapshot = getStartupHealthSnapshot(config, deps); + expect(snapshot).toMatchObject({ diagnosticStale: true, status: "at-risk", rebootSafe: false }); + // Snapshot has returned while the manually controlled probe remains unresolved. + expect(getStartupHealthSnapshot(config, deps)).toEqual(snapshot); + const fresh = getCachedStartupHealth(config, deps); + const replacement = deriveStartupHealth({ ...base, routingKind: "custom-remote" }); + release(replacement); + expect(await fresh).toBe(replacement); + expect(calls).toBe(1); + invalidateStartupHealthCache(); + }); - 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)), - ]); + test.each(["reject", "throw"])("detached snapshot probe handles %s and permits a later retry", async (failure) => { + invalidateStartupHealthCache(); + const config = { codexAutoStart: true }; + const failed = getStartupHealthSnapshot(config, { probe: () => { + if (failure === "throw") throw new Error("controlled probe failure"); + return Promise.reject(new Error("controlled probe failure")); + } }); + expect(failed.diagnosticStale).toBe(true); + const settled = await getCachedStartupHealth(config, { waitForProbe: probe => probe }); + expect(settled.diagnosticStale).toBe(true); + const replacement = deriveStartupHealth({ ...base, routingKind: "native" }); + expect(await getCachedStartupHealth(config, { probe: async () => replacement, waitForProbe: probe => probe })).toBe(replacement); + invalidateStartupHealthCache(); + }); - expect(response?.status).toBe(200); - const body = await response!.json() as { startupHealth?: { diagnosticStale?: boolean } }; - expect(body.startupHealth?.diagnosticStale).toBe(true); + test("invalidated probe cannot replace or clear a newer flight", async () => { + invalidateStartupHealthCache(); + const config = { codexAutoStart: true }; + type Health = ReturnType; + let oldRelease!: (value: Health) => void; + let newRelease!: (value: Health) => void; + const oldProbe = new Promise(resolve => { oldRelease = resolve; }); + const newProbe = new Promise(resolve => { newRelease = resolve; }); + getStartupHealthSnapshot(config, { probe: () => oldProbe }); + const oldWait = getCachedStartupHealth(config, { waitForProbe: probe => probe }); + invalidateStartupHealthCache(); + getStartupHealthSnapshot(config, { probe: () => newProbe }); + const newer = getCachedStartupHealth(config, { waitForProbe: probe => probe }); + oldRelease(deriveStartupHealth(base)); + await oldWait; + let spuriousCalls = 0; + getStartupHealthSnapshot(config, { probe: async () => { spuriousCalls += 1; return deriveStartupHealth(base); } }); + const expected = deriveStartupHealth({ ...base, routingKind: "native" }); + newRelease(expected); + expect(await newer).toBe(expected); + expect(getStartupHealthSnapshot(config)).toBe(expected); + expect(spuriousCalls).toBe(0); invalidateStartupHealthCache(); }); });