From dcd2d0740301785ec624168073c7bfc59c10bb9f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:53:36 +0900 Subject: [PATCH 01/18] fix(search): retry clean empty answers without masking truncation Co-authored-by: Cortes Ventures --- .../content/docs/reference/proxy-formats.md | 7 ++ src/web-search/loop.ts | 58 ++++++++- structure/runtime.md | 4 + tests/web-search/web-search.test.ts | 116 ++++++++++++++++++ 4 files changed, 182 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 8975c944cf..1bd63cac79 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -24,6 +24,13 @@ should select among several targets. Credential-bearing model, image, video, and search requests do not automatically follow HTTP redirects, including same-origin redirects. Configure the final upstream API URL instead of a redirecting alias. A redirect does not cause the server to resend credentials or the request body to its destination. The response owner retains its existing error or relay behavior; native Responses and compact routes can return the original 3xx and `Location` to the client. Client redirect behavior is separate from this server transport policy. +## Empty search answers + +After hosted search, a clean but empty forced-answer pass receives one additional answer +attempt with tools removed and existing results retained. This can incur another model +request. A second empty answer fails; malformed calls and provider refusal or truncation +outcomes are preserved without this retry. + ## Endpoint overview | Client surface | Endpoint | Successful non-stream result | Successful stream or socket result | diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 99b275ed8d..8feceb4c27 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -3,6 +3,7 @@ import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, Ocx import { namespacedToolName, toolChoiceToolPredicate } from "../types"; import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaque-metadata"; import type { AttemptRecoveryKind } from "../usage/log"; +import { isTruncatedStopReason } from "../responses/truncated-stop-reason"; import { bridgeToResponsesSSE } from "../bridge"; import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor"; import { runAnthropicWebSearch } from "./anthropic-executor"; @@ -230,6 +231,24 @@ function forcedAnswerNudge(): OcxMessage { }; } +/** + * Transient developer-role nudge for the ONE recovery pass after a forced answer came back empty. + * The recovery also removes every tool, so the model has nothing to call and can only return text; + * this turn says so explicitly rather than relying on the removal alone. Like {@link forcedAnswerNudge} + * it is iteration-local and never touches the persisted `messages`. + */ +function forcedAnswerRetryNudge(): OcxMessage { + return { + role: "developer", + content: + "Your previous response contained no usable answer. Web search has finished for this turn and " + + "no tools are available for this response. Answer the user's question now in assistant text, " + + "using the web search results already gathered above. If those results are insufficient, say " + + "what is missing instead of returning an empty response.", + timestamp: Date.now(), + }; +} + function jsonError(status: number, message: string): Response { return new Response(JSON.stringify({ error: { message, type: "upstream_error", code: null } }), { status, @@ -370,7 +389,9 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise 0 + let iterMessages: OcxMessage[] = forceAnswer && executedSearchCount > 0 ? [...messages, forcedAnswerNudge()] : messages; + // #1001 follow-up: the recovery pass for an empty forced answer. Removing every tool leaves the + // model nothing to call, and the extra developer turn asks it for the text it just failed to + // produce. `toolChoice: "none"` is what drops those definitions in the adapter, so the retry + // cannot repeat the same empty or tool-shaped response. + const recoveringEmptyAnswer = forceAnswer && emptyAnswerRetries > 0; + if (recoveringEmptyAnswer) iterMessages = [...iterMessages, forcedAnswerRetryNudge()]; const iterParsed: OcxParsedRequest = { ...parsed, stream: true, - context: { ...parsed.context, messages: iterMessages, tools: forceAnswer ? toolsNoWebSearch : allTools }, + ...(recoveringEmptyAnswer ? { options: { ...parsed.options, toolChoice: "none" as const } } : {}), + context: { ...parsed.context, messages: iterMessages, tools: recoveringEmptyAnswer ? [] : forceAnswer ? toolsNoWebSearch : allTools }, }; // One cumulative header deadline spans every pool-key 429 rotation in this model iteration. // clear() stops only its timer after final headers; the direct turn signal remains attached to @@ -847,9 +875,33 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise event.type === "done"); + if (terminalEvent?.type === "done" && isTruncatedStopReason(terminalEvent.stopReason)) { + // A provider refusal or truncation is authoritative, even without text. + // Preserve it once; neither an empty-answer retry nor a generic 502 applies. + yield* replay(split.passthrough.slice(split.streamedPassthroughCount)); + return; + } if (terminalEvent?.type === "done" && (split.hasMalformedToolCall || (!split.hasRealToolCall && !hasVisibleAssistantText(split.passthrough)))) { + // #1001 fixed the silent success by failing here. A malformed call still fails: it + // reports a protocol problem, and replaying it would only re-ask an unwell upstream. + // Silence is different — it is recoverable, so retry exactly once with the results + // already gathered before failing the turn. + console.warn("[web-search-loop] unusable forced answer", JSON.stringify({ + model: parsed.modelId, + recoveryAttempt: emptyAnswerRetries, + searchCalls: split.calls.length, + malformed: split.hasMalformedToolCall, + stopReason: terminalEvent.stopReason, + eventTypes: [...new Set(split.passthrough.map(event => event.type))], + })); + if (!split.hasMalformedToolCall && !split.hasRealToolCall && emptyAnswerRetries === 0) { + emptyAnswerRetries++; + console.warn("[web-search-loop] empty forced answer — retrying once without tools"); + yield { type: "heartbeat" }; + continue; + } throw new LoopError(502, "forced-answer pass produced no usable assistant output"); } } diff --git a/structure/runtime.md b/structure/runtime.md index 495745051e..ea8e98e277 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -192,3 +192,7 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +### Empty forced search answers + +`src/web-search/loop.ts` makes at most one extra answer attempt after a clean forced-answer terminal with no visible output or tool call. The recovery has no tools and reuses gathered search results. Malformed calls fail, and recognized refusal/truncation terminals pass through unchanged, including empty or partial answers. The extra generation may incur provider usage. diff --git a/tests/web-search/web-search.test.ts b/tests/web-search/web-search.test.ts index e182308a0d..5c41ad5e82 100644 --- a/tests/web-search/web-search.test.ts +++ b/tests/web-search/web-search.test.ts @@ -133,6 +133,122 @@ describe("issue #1001 — forced-answer passes must produce usable output", () = expect(frames.some(frame => frame.event === "response.completed")).toBe(true); expect(frames.some(frame => frame.event === "response.failed")).toBe(false); }); + + // #1001 chose to fail rather than complete silently, which turned silence into a dead turn: + // the user sees "stream disconnected before completion: forced-answer pass produced no usable + // assistant output". Silence is recoverable, so the pass is retried once with no tools before + // the same error is reported. Malformed calls still fail immediately. + describe("empty forced answer recovery", () => { + function sequenceAdapter(passes: AdapterEvent[][], seen: OcxParsedRequest[]): ProviderAdapter { + let pass = 0; + return { + name: "sequence", + buildRequest: (request) => { + seen.push(request); + return { url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }; + }, + fetchResponse: async () => new Response("wire", { status: 200 }), + async *parseStream() { + for (const event of passes[Math.min(pass++, passes.length - 1)] ?? []) yield event; + }, + async parseResponse() { + throw new Error("parseResponse must be unreachable"); + }, + }; + } + + async function drivePasses(passes: AdapterEvent[][], seen: OcxParsedRequest[] = [], ordinaryTool = false) { + const response = await runWithWebSearch({ + parsed: parseRequest({ model: "routed/model", input: "hi", stream: true, tools: [{ type: "web_search" }, ...(ordinaryTool ? [{ type: "function", name: "fixture", parameters: { type: "object", properties: {} } }] : [])] }), + adapter: sequenceAdapter(passes, seen), + forwardProvider, + hostedTool: { type: "web_search" }, + selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, + maxSearches: 1, + }); + return collectSse(response.body!); + } + + test("an empty forced pass is retried once and completes", async () => { + const frames = await drivePasses([ + webSearchFirstPass, + [{ type: "done" }], + [{ type: "text_delta", text: "recovered answer" }, { type: "done" }], + ]); + expect(frames.some(frame => frame.event === "response.completed")).toBe(true); + expect(frames.some(frame => frame.event === "response.failed")).toBe(false); + }); + + test("the recovery pass asks for text with every tool removed", async () => { + const seen: OcxParsedRequest[] = []; + await drivePasses([ + webSearchFirstPass, + [{ type: "done" }], + [{ type: "text_delta", text: "recovered answer" }, { type: "done" }], + ], seen); + // The search pass plus the empty forced pass plus exactly one recovery — no extra upstream call. + expect(seen).toHaveLength(3); + const recovery = seen[2]!; + expect(recovery.options.toolChoice).toBe("none"); + expect(recovery.context.tools).toEqual([]); + // The results gathered by the search reach the recovery turn as a tool result ... + expect(recovery.context.messages.filter(message => message.role === "toolResult")).toHaveLength(1); + // ... and the recovery turn carries the developer nudge that asks for the missing text. + expect(recovery.context.messages.some(message => + message.role === "developer" && String(message.content).includes("no tools are available"))) + .toBe(true); + }); + + test("recovery removes ordinary tools as well as web search", async () => { + const seen: OcxParsedRequest[] = []; + await drivePasses([webSearchFirstPass, [{ type: "done" }], [{ type: "text_delta", text: "answer" }, { type: "done" }]], seen, true); + expect(seen).toHaveLength(3); + expect(seen[1]!.context.tools.length).toBeGreaterThan(0); + expect(seen[2]!.context.tools).toEqual([]); + expect(seen[2]!.options.toolChoice).toBe("none"); + }); + + for (const [stopReason, reason] of [["refusal", "content_filter"], ["content_filter", "content_filter"], ["max_tokens", "max_output_tokens"], ["length", "max_output_tokens"]]) { + for (const partial of [false, true]) { + test(`${stopReason} partial=${partial} stays authoritative without a retry`, async () => { + const seen: OcxParsedRequest[] = []; + const terminalPass: AdapterEvent[] = [ + ...(partial ? [{ type: "text_delta" as const, text: "partial answer" }] : []), + { type: "done", stopReason }, + ]; + const frames = await drivePasses([webSearchFirstPass, terminalPass, [{ type: "done" }]], seen); + expect(seen).toHaveLength(2); + expect(frames.filter(frame => ["response.incomplete", "response.completed", "response.failed"].includes(frame.event)).map(frame => frame.event)).toEqual(["response.incomplete"]); + expect(frames.find(frame => frame.event === "response.incomplete")!.data.response.incomplete_details.reason).toBe(reason); + if (partial) expect(frames.filter(frame => frame.event === "response.output_text.delta").map(frame => frame.data.delta).join("")).toBe("partial answer"); + }); + } + } + + test("a persistent empty forced pass still fails after the one recovery", async () => { + const seen: OcxParsedRequest[] = []; + const frames = await drivePasses([ + webSearchFirstPass, + [{ type: "done" }], + [{ type: "done" }], + ], seen); + expect(seen).toHaveLength(3); + expect(frames.some(frame => frame.event === "response.failed")).toBe(true); + expect(frames.some(frame => frame.event === "response.completed")).toBe(false); + }); + + test("a malformed forced call is not retried", async () => { + const seen: OcxParsedRequest[] = []; + const frames = await drivePasses([ + webSearchFirstPass, + [{ type: "tool_call_start", id: "", name: "" }, { type: "tool_call_end" }, { type: "done" }], + [{ type: "text_delta", text: "recovered answer" }, { type: "done" }], + ], seen); + expect(seen).toHaveLength(2); + expect(frames.some(frame => frame.event === "response.failed")).toBe(true); + }); + }); }); const routedProvider: OcxProviderConfig = { From 45f703f356b20172ffd9a7301a9ecaf967deccdf Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:54:48 +0900 Subject: [PATCH 02/18] test(search): narrow terminal fixture projection --- tests/web-search/web-search.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/web-search/web-search.test.ts b/tests/web-search/web-search.test.ts index 5c41ad5e82..61abb904fd 100644 --- a/tests/web-search/web-search.test.ts +++ b/tests/web-search/web-search.test.ts @@ -219,8 +219,9 @@ describe("issue #1001 — forced-answer passes must produce usable output", () = ]; const frames = await drivePasses([webSearchFirstPass, terminalPass, [{ type: "done" }]], seen); expect(seen).toHaveLength(2); - expect(frames.filter(frame => ["response.incomplete", "response.completed", "response.failed"].includes(frame.event)).map(frame => frame.event)).toEqual(["response.incomplete"]); - expect(frames.find(frame => frame.event === "response.incomplete")!.data.response.incomplete_details.reason).toBe(reason); + expect(frames.filter(frame => ["response.incomplete", "response.completed", "response.failed"].includes(frame.event ?? "")).map(frame => frame.event)).toEqual(["response.incomplete"]); + const terminalResponse = frames.find(frame => frame.event === "response.incomplete")!.data.response as { incomplete_details: { reason: string } }; + expect(terminalResponse.incomplete_details.reason).toBe(reason); if (partial) expect(frames.filter(frame => frame.event === "response.output_text.delta").map(frame => frame.data.delta).join("")).toBe("partial answer"); }); } From 3652da790b58177f5ae7eebecb9c8ba3527e9109 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:55:23 +0900 Subject: [PATCH 03/18] test(search): exercise live output truncation without duplicate replay --- tests/web-search/web-search.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/web-search/web-search.test.ts b/tests/web-search/web-search.test.ts index 61abb904fd..9bf7c02444 100644 --- a/tests/web-search/web-search.test.ts +++ b/tests/web-search/web-search.test.ts @@ -157,7 +157,7 @@ describe("issue #1001 — forced-answer passes must produce usable output", () = }; } - async function drivePasses(passes: AdapterEvent[][], seen: OcxParsedRequest[] = [], ordinaryTool = false) { + async function drivePasses(passes: AdapterEvent[][], seen: OcxParsedRequest[] = [], ordinaryTool = false, liveOutput = false) { const response = await runWithWebSearch({ parsed: parseRequest({ model: "routed/model", input: "hi", stream: true, tools: [{ type: "web_search" }, ...(ordinaryTool ? [{ type: "function", name: "fixture", parameters: { type: "object", properties: {} } }] : [])] }), adapter: sequenceAdapter(passes, seen), @@ -166,6 +166,7 @@ describe("issue #1001 — forced-answer passes must produce usable output", () = selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, + streamRoutedModelOutput: liveOutput, }); return collectSse(response.body!); } @@ -217,7 +218,7 @@ describe("issue #1001 — forced-answer passes must produce usable output", () = ...(partial ? [{ type: "text_delta" as const, text: "partial answer" }] : []), { type: "done", stopReason }, ]; - const frames = await drivePasses([webSearchFirstPass, terminalPass, [{ type: "done" }]], seen); + const frames = await drivePasses([webSearchFirstPass, terminalPass, [{ type: "done" }]], seen, false, true); expect(seen).toHaveLength(2); expect(frames.filter(frame => ["response.incomplete", "response.completed", "response.failed"].includes(frame.event ?? "")).map(frame => frame.event)).toEqual(["response.incomplete"]); const terminalResponse = frames.find(frame => frame.event === "response.incomplete")!.data.response as { incomplete_details: { reason: string } }; From 59ec04b907b56a324971f23fd5350795f3039021 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:00:49 +0900 Subject: [PATCH 04/18] fix(cursor): preserve first overflow and bound stable-thread remints Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- .../content/docs/reference/proxy-formats.md | 8 + scripts/test-layout/layout.json | 1 + src/adapters/cursor.ts | 176 ++++++---- src/adapters/cursor/cursor-errors.ts | 12 + src/adapters/cursor/thread-continuity.ts | 87 +++++ structure/providers/cursor.md | 4 + tests/fixtures/test-layout-expected.json | 1 + tests/providers/cursor/cursor-adapter.test.ts | 327 ++++++++++++++++++ .../cursor-continuity-retention.test.ts | 27 ++ 9 files changed, 570 insertions(+), 73 deletions(-) create mode 100644 tests/providers/cursor/cursor-continuity-retention.test.ts diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 8975c944cf..865cb63834 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -24,6 +24,14 @@ should select among several targets. Credential-bearing model, image, video, and search requests do not automatically follow HTTP redirects, including same-origin redirects. Configure the final upstream API URL instead of a redirecting alias. A redirect does not cause the server to resend credentials or the request body to its destination. The response owner retains its existing error or relay behavior; native Responses and compact routes can return the original 3xx and `Location` to the client. Client redirect behavior is separate from this server transport policy. +## Cursor context overflow + +Cursor's first bare context overflow is surfaced to the client. Later eligible requests +with a stable client thread may recover with up to three conversation remints per retained +scope. The in-memory allowance expires after one idle hour, eviction, or restart. Requests +without a stable thread, tool-result resumes, partial output, compaction and quota errors do +not use this recovery. This does not infer whether a task is making progress. + ## Endpoint overview | Client surface | Endpoint | Successful non-stream result | Successful stream or socket result | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7241f26266..8a40a91e13 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -538,6 +538,7 @@ "crash-guard.test.ts": "service", "credential-redirect-guard.test.ts": "lib", "cursor-adapter.test.ts": "providers/cursor", + "cursor-continuity-retention.test.ts": "providers/cursor", "cursor-arg-normalize.test.ts": "providers/cursor", "cursor-blob-integrity.test.ts": "providers/cursor", "cursor-blob.test.ts": "providers/cursor", diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 7382c25d8c..a240ae1982 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -3,7 +3,7 @@ import type { AdapterEvent, OcxProviderConfig } from "../types"; import type { ProviderAdapter } from "./base"; import { isTranslatorBudgetExceededError } from "../lib/translator-budget"; import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy"; -import { isCursorBenignCancelError, isCursorInvalidArgumentError, isCursorRootEnvelopeError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors"; +import { isCursorBenignCancelError, isCursorInvalidArgumentError, isCursorOverflowRemintCandidate, isCursorRootEnvelopeError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors"; import { cursorCheckpointModelAffinityId, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery"; import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store"; import { mapCursorServerMessage } from "./cursor/message-mapper"; @@ -31,7 +31,14 @@ import { debugProviderDiagnostic } from "../lib/debug"; import { isDebugEnabled } from "../lib/debug-settings"; import { createAdapterTierMetadata } from "../providers/fastwire"; import { estimateTokens } from "../lib/token-estimate"; -import { rememberCursorThreadConversation } from "./cursor/thread-continuity"; +import { + cursorOverflowRemintScopeKey, + markCursorOverflowSurfaced, + recordCursorOverflowRemint, + rememberCursorThreadConversation, + shouldSkipCursorOverflowRemint, + shouldSurfaceCursorOverflowFirst, +} from "./cursor/thread-continuity"; import { runCursorTurnWithRetry } from "./cursor/transport-retry"; import { cursorRequestHasShellAlias, cursorRequestUsesCodeMode } from "./cursor/tool-definitions"; import { @@ -399,84 +406,107 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda ); }; - try { - await runOnce(request); - } catch (err) { - const outputGuardRetryText = - err instanceof CursorToolResultEchoError - ? CURSOR_ECHO_RETRY_CONTINUATION_TEXT - : err instanceof CursorRoutingCommentaryError - ? CURSOR_ROUTING_COMMENTARY_RETRY_TEXT - : undefined; - // One-shot corrective retry for guarded external output (devlog 260826 gap-10/11). - // The quarantine guarantees no client-visible delta escaped, so a fresh-conversation - // retry is safe. A second rejection propagates as an error rather than looping. - if ( - outputGuardRetryText - && !emittedOutput - && !replayUnsafe - && !incoming.abortSignal?.aborted - ) { - debugProviderDiagnostic( - "cursor", - err instanceof CursorToolResultEchoError - ? "envelope-echo-retry" - : "routing-commentary-retry", - { - wireModel: request.modelId, - conversationHash: request.conversationId.slice(0, 16), - }, + const remintConversationId = (failedConversationId: string) => { + lastTransport = undefined; + _parsed._cursorConversationId = undefined; + const next = createCursorRequest(_parsed, { forceFreshConversation: true }); + rekeyContextUsage(failedConversationId, next.conversationId); + _parsed._cursorConversationId = next.conversationId; + // Persist recovery for store:false clients that send any stable Cursor thread owner, so + // the next turn does not recompute the stale deterministic thread hash. Isolated helper / + // compaction turns must not park their throwaway id under the parent or Desktop owner. + const threadOwner = cursorClientThreadOwner(_parsed); + if (threadOwner && _parsed._cursorIsolateConversation !== true) { + rememberCursorThreadConversation( + threadOwner, + next.conversationId, + _parsed._cursorIdentityScope, ); - const echoedConversationId = request.conversationId; - lastTransport = undefined; - _parsed._cursorConversationId = undefined; - request = { - ...createCursorRequest(_parsed, { forceFreshConversation: true }), - echoRetryContinuationText: outputGuardRetryText, - }; - rekeyContextUsage(echoedConversationId, request.conversationId); - _parsed._cursorConversationId = request.conversationId; - const echoThreadOwner = cursorClientThreadOwner(_parsed); - if (echoThreadOwner && _parsed._cursorIsolateConversation !== true) { - rememberCursorThreadConversation( - echoThreadOwner, - request.conversationId, - _parsed._cursorIdentityScope, - ); - } + } + return next; + }; + + for (;;) { + try { await runOnce(request); - } else { - // One-shot fallback for external-model Connect invalid_argument before any - // non-heartbeat output. Retries apply only to safe plain-user turns; tool-result - // resumes, local exec/MCP side effects, and already-emitted output fail closed. + break; + } catch (err) { + const outputGuardRetryText = + err instanceof CursorToolResultEchoError + ? CURSOR_ECHO_RETRY_CONTINUATION_TEXT + : err instanceof CursorRoutingCommentaryError + ? CURSOR_ROUTING_COMMENTARY_RETRY_TEXT + : undefined; + // One-shot corrective retry for guarded external output (devlog 260826 gap-10/11). + // The quarantine guarantees no client-visible delta escaped, so a fresh-conversation + // retry is safe. A second rejection propagates as an error rather than looping. if ( - !isCursorInvalidArgumentError(err) - || !isCursorExternalWireModel(request.modelId) - || lastRawIsToolResult - || emittedOutput - || replayUnsafe - || incoming.abortSignal?.aborted + outputGuardRetryText + && !emittedOutput + && !replayUnsafe + && !incoming.abortSignal?.aborted ) { - throw err; - } - const failedConversationId = request.conversationId; - lastTransport = undefined; - _parsed._cursorConversationId = undefined; - request = createCursorRequest(_parsed, { forceFreshConversation: true }); - rekeyContextUsage(failedConversationId, request.conversationId); - _parsed._cursorConversationId = request.conversationId; - // Persist recovery for store:false clients that send any stable Cursor thread owner, so - // the next turn does not recompute the stale deterministic thread hash. Isolated helper / - // compaction turns must not park their throwaway id under the parent or Desktop owner. - const threadOwner = cursorClientThreadOwner(_parsed); - if (threadOwner && _parsed._cursorIsolateConversation !== true) { - rememberCursorThreadConversation( - threadOwner, - request.conversationId, + debugProviderDiagnostic( + "cursor", + err instanceof CursorToolResultEchoError + ? "envelope-echo-retry" + : "routing-commentary-retry", + { + wireModel: request.modelId, + conversationHash: request.conversationId.slice(0, 16), + }, + ); + const echoedConversationId = request.conversationId; + request = { + ...remintConversationId(echoedConversationId), + echoRetryContinuationText: outputGuardRetryText, + }; + await runOnce(request); + break; + } else { + const overflowRemintSafe = + !lastRawIsToolResult + && !emittedOutput + && !replayUnsafe + && request.contextUsageStoreCheckpoints !== false + && !incoming.abortSignal?.aborted; + const overflowScopeKey = cursorOverflowRemintScopeKey( + cursorClientThreadOwner(_parsed), _parsed._cursorIdentityScope, ); + if ( + overflowScopeKey + && overflowRemintSafe + && isCursorOverflowRemintCandidate(err, requestSizeContext) + ) { + if (shouldSkipCursorOverflowRemint(overflowScopeKey)) throw err; + if (shouldSurfaceCursorOverflowFirst(overflowScopeKey)) { + markCursorOverflowSurfaced(overflowScopeKey); + throw err; + } + if (!recordCursorOverflowRemint(overflowScopeKey)) throw err; + if (inheritedCheckpointRef) invalidateCursorCheckpoint(inheritedCheckpointRef); + request = remintConversationId(request.conversationId); + continue; + } + + // One-shot fallback for external-model Connect invalid_argument before any + // non-heartbeat output. Retries apply only to safe plain-user turns; tool-result + // resumes, local exec/MCP side effects, and already-emitted output fail closed. + if ( + !isCursorInvalidArgumentError(err) + || !isCursorExternalWireModel(request.modelId) + || lastRawIsToolResult + || emittedOutput + || replayUnsafe + || incoming.abortSignal?.aborted + ) { + throw err; + } + request = remintConversationId(request.conversationId); + await runOnce(request); + break; } - await runOnce(request); } } if ( diff --git a/src/adapters/cursor/cursor-errors.ts b/src/adapters/cursor/cursor-errors.ts index b7005db3c2..fba44b2f99 100644 --- a/src/adapters/cursor/cursor-errors.ts +++ b/src/adapters/cursor/cursor-errors.ts @@ -191,6 +191,18 @@ function bareReLooksLikeOverflow(context?: CursorSizeContext): boolean { return estimatedInputTokens >= OVERFLOW_MIN_FRACTION * contextWindow; } +/** + * True when a transport error is the bare 0-token resource_exhausted overflow shape + * (not quota/rate) that should surface for Codex compact or remint on later hits. + */ +export function isCursorOverflowRemintCandidate(err: unknown, sizeContext?: CursorSizeContext): boolean { + const message = errorMessage(err); + if (!message) return false; + const lower = message.toLowerCase(); + if (!isCursorZeroTokenResourceExhausted(lower)) return false; + return classifyCursorError(message, sizeContext) === "Cursor context limit exceeded"; +} + export function isCursorZeroTokenResourceExhausted(lowerMessage: string): boolean { if (!lowerMessage.includes("resource_exhausted") && !lowerMessage.includes("resource exhausted")) return false; // Any explicit quota/rate cue wins: this is a real 429. diff --git a/src/adapters/cursor/thread-continuity.ts b/src/adapters/cursor/thread-continuity.ts index 6cb0e2cf35..aa3c3dac32 100644 --- a/src/adapters/cursor/thread-continuity.ts +++ b/src/adapters/cursor/thread-continuity.ts @@ -65,3 +65,90 @@ export function lookupCursorThreadConversation( export function clearCursorThreadContinuityForTests(): void { overrides.clear(); } + +/** Max conversation-id remints after the first surfaced overflow per retained scope. */ +export const CURSOR_OVERFLOW_REMINT_MAX = 3; +export const CURSOR_OVERFLOW_REMINT_TTL_MS = 60 * 60 * 1000; +export const CURSOR_OVERFLOW_REMINT_MAX_ENTRIES = 2_048; + +type OverflowRemintState = { + surfaced: boolean; + remintCount: number; + skip: boolean; + updatedAt: number; +}; + +const overflowRemintByScope = new Map(); + +function pruneOverflowRemints(at: number): void { + for (const [scopeKey, entry] of overflowRemintByScope) { + if (at - entry.updatedAt > CURSOR_OVERFLOW_REMINT_TTL_MS) overflowRemintByScope.delete(scopeKey); + } + while (overflowRemintByScope.size > CURSOR_OVERFLOW_REMINT_MAX_ENTRIES) { + const oldest = overflowRemintByScope.keys().next().value; + if (oldest === undefined) break; + overflowRemintByScope.delete(oldest); + } +} + +function overflowRemintEntry(scopeKey: string): OverflowRemintState { + const at = now(); + pruneOverflowRemints(at); + const existing = overflowRemintByScope.get(scopeKey); + if (existing) { + existing.updatedAt = at; + overflowRemintByScope.delete(scopeKey); + overflowRemintByScope.set(scopeKey, existing); + return existing; + } + const fresh: OverflowRemintState = { surfaced: false, remintCount: 0, skip: false, updatedAt: at }; + overflowRemintByScope.set(scopeKey, fresh); + pruneOverflowRemints(at); + return fresh; +} + +/** Stable client-thread ownership survives conversation remints; wire ids alone do not. */ +export function cursorOverflowRemintScopeKey( + threadOwner: string | undefined, + identityScope?: string, +): string | null { + if (!threadOwner) return null; + return `overflow\0${cursorThreadScopeKey(threadOwner, identityScope)}`; +} + +/** True until the first overflow for this scope has been surfaced for Codex compact. */ +export function shouldSurfaceCursorOverflowFirst(scopeKey: string): boolean { + pruneOverflowRemints(now()); + return overflowRemintByScope.get(scopeKey)?.surfaced !== true; +} + +export function markCursorOverflowSurfaced(scopeKey: string): void { + const entry = overflowRemintEntry(scopeKey); + entry.surfaced = true; +} + +export function shouldSkipCursorOverflowRemint(scopeKey: string): boolean { + pruneOverflowRemints(now()); + const entry = overflowRemintByScope.get(scopeKey); + return entry?.skip === true || (entry?.remintCount ?? 0) >= CURSOR_OVERFLOW_REMINT_MAX; +} + +/** Record one overflow remint; returns false when the cap is exhausted. */ +export function recordCursorOverflowRemint(scopeKey: string): boolean { + const entry = overflowRemintEntry(scopeKey); + if (entry.skip || entry.remintCount >= CURSOR_OVERFLOW_REMINT_MAX) { + entry.skip = true; + return false; + } + entry.remintCount += 1; + return true; +} + +export function clearCursorOverflowRemintForTests(): void { + overflowRemintByScope.clear(); +} + +export function cursorOverflowRemintCountForTests(): number { + pruneOverflowRemints(now()); + return overflowRemintByScope.size; +} diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index be42793e0b..3d734a33ed 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -82,3 +82,7 @@ constraints cannot widen the canonical shape. Bare shell bridge names are reject on the freeform path. Namespaced tools do not acquire bare-shell behavior. Regression coverage lives in `tests/providers/cursor/cursor-tool-definitions.test.ts`. + +## Overflow remint boundary + +`src/adapters/cursor.ts` surfaces the first bare context overflow before attempting conversation remint on later eligible requests. `cursorClientThreadOwner` recognizes both client thread aliases; `src/adapters/cursor/thread-continuity.ts` limits recovery to three remints per retained identity-scoped owner, with a one-hour idle TTL and 2,048-entry bound. Conversation-only requests have no stable owner and do not automatically remint. Quota/rate errors, tool-result resumes, partial output, local side effects and compaction remain fail-closed. Retention expiry, eviction or process restart resets the in-memory allowance; this is not a persistent lifetime cap or semantic-progress policy. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 62724ffed2..cf071d04fc 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -373,6 +373,7 @@ "crash-guard.test.ts": "service", "credential-redirect-guard.test.ts": "lib", "cursor-adapter.test.ts": "providers/cursor", + "cursor-continuity-retention.test.ts": "providers/cursor", "cursor-arg-normalize.test.ts": "providers/cursor", "cursor-blob-integrity.test.ts": "providers/cursor", "cursor-blob.test.ts": "providers/cursor", diff --git a/tests/providers/cursor/cursor-adapter.test.ts b/tests/providers/cursor/cursor-adapter.test.ts index a86df7c243..82049b83f4 100644 --- a/tests/providers/cursor/cursor-adapter.test.ts +++ b/tests/providers/cursor/cursor-adapter.test.ts @@ -4,6 +4,7 @@ import { cursorExecDeniedMessage, } from "../../../src/adapters/cursor"; import { + clearCursorOverflowRemintForTests, clearCursorThreadContinuityForTests, lookupCursorThreadConversation, } from "../../../src/adapters/cursor/thread-continuity"; @@ -817,3 +818,329 @@ describe("Cursor adapter live transport", () => { clearCursorCheckpointsForTests(); }); }); +const LARGE_OVERFLOW_CONTENT = "word ".repeat(100_000); + +function bareOverflowError(): Error { + return Object.assign( + new Error("Cursor context limit exceeded: Cursor Connect error resource_exhausted: Error"), + { code: "resource_exhausted" }, + ); +} + +function overflowTurnBody(threadId?: string): OcxParsedRequest { + return { + modelId: "cursor/auto", + context: { messages: [{ role: "user", content: LARGE_OVERFLOW_CONTENT, timestamp: 1 }] }, + stream: false, + options: {}, + _cursorIdentityScope: "acct-overflow-remint", + ...(threadId ? { _clientThreadId: threadId } : { _cursorConversationId: "cursor_overflow_base" }), + }; +} + +describe("Cursor overflow conversation remint", () => { + test("first bare overflow surfaces without reminting the conversation id", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const seen: string[] = []; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run(request) { + attempts += 1; + seen.push(request.conversationId); + throw bareOverflowError(); + }, + writeClient() {}, + }), + }); + + const body = overflowTurnBody("overflow-surface-first"); + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + + expect(attempts).toBe(1); + expect(seen).toHaveLength(1); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + message: expect.stringContaining("Cursor context limit exceeded"), + }); + }); + + test("second overflow remints and persists thread override", async () => { + clearCursorOverflowRemintForTests(); + clearCursorThreadContinuityForTests(); + let attempts = 0; + const seen: string[] = []; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run(request) { + attempts += 1; + seen.push(request.conversationId); + if (attempts === 1) { + throw bareOverflowError(); + } + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + }), + rekeyContextUsage: () => {}, + }); + + const threadId = "overflow-remint-thread"; + const body = overflowTurnBody(threadId); + + const surfaceEvents: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => surfaceEvents.push(event)); + expect(attempts).toBe(1); + expect(surfaceEvents.some(event => event.type === "error")).toBe(true); + + seen.length = 0; + attempts = 0; + const remintEvents: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => remintEvents.push(event)); + + expect(attempts).toBe(2); + expect(seen).toHaveLength(2); + expect(seen[1]).not.toBe(seen[0]); + expect(remintEvents.some(event => event.type === "done")).toBe(true); + expect(lookupCursorThreadConversation(threadId, "acct-overflow-remint")).toBe(seen[1]); + expect(body._cursorConversationId).toBe(seen[1]); + }); + + test("fourth overflow skips remint after surface-first and three remints", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run() { + attempts += 1; + throw bareOverflowError(); + }, + writeClient() {}, + }), + }); + + const body = overflowTurnBody("overflow-cap-skip"); + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + expect(attempts).toBe(1); + + attempts = 0; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + + expect(attempts).toBe(4); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + message: expect.stringContaining("Cursor context limit exceeded"), + }); + }); + + test("quota-cue resource_exhausted does not remint and surfaces as rate limit", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run() { + attempts += 1; + throw Object.assign( + new Error("Cursor rate limit exceeded: resource_exhausted: too many requests"), + { code: "resource_exhausted" }, + ); + }, + writeClient() {}, + }), + }); + + const body = overflowTurnBody("overflow-quota-cue"); + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + + expect(attempts).toBe(1); + expect(events[0]).toMatchObject({ + type: "error", + message: expect.stringContaining("Cursor rate limit exceeded"), + }); + }); + + test("does not overflow-remint on tool-result resumes", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run() { + attempts += 1; + throw bareOverflowError(); + }, + writeClient() {}, + }), + }); + + const body: OcxParsedRequest = { + modelId: "cursor/auto", + context: { + messages: [ + { role: "user", content: LARGE_OVERFLOW_CONTENT, timestamp: 1 }, + { + role: "assistant", + model: "cursor/auto", + timestamp: 2, + content: [{ type: "toolCall", id: "call_1", name: "read_file", namespace: "mcp__fs", arguments: { path: "a.txt" } }], + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "read_file", + toolNamespace: "mcp__fs", + content: "FILE CONTENTS HERE", + isError: false, + timestamp: 3, + }, + ], + }, + stream: false, + options: {}, + _cursorConversationId: "cursor_overflow_tool", + _cursorIdentityScope: "acct-overflow-remint", + }; + + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + expect(attempts).toBe(1); + }); + + test("does not overflow-remint compaction turns", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const seen: string[] = []; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run(request) { + attempts += 1; + seen.push(request.conversationId); + throw bareOverflowError(); + }, + writeClient() {}, + }), + }); + + const body = overflowTurnBody("overflow-compaction"); + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + attempts = 0; + seen.length = 0; + body._compactionRequest = true; + body._cursorIsolateConversation = true; + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + + expect(attempts).toBe(1); + expect(seen).toHaveLength(1); + }); + + test("does not overflow-remint after non-heartbeat output was emitted", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run() { + attempts += 1; + yield { type: "text", text: "partial" } satisfies CursorServerMessage; + throw bareOverflowError(); + }, + writeClient() {}, + }), + }); + + const body = overflowTurnBody("overflow-after-output"); + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + + expect(attempts).toBe(1); + expect(events.some(event => event.type === "text_delta")).toBe(true); + expect(events.some(event => event.type === "error")).toBe(true); + }); +}); + + +describe("Cursor overflow accounting across requests", () => { + for (const ownerField of ["_clientThreadId", "_cursorClientThreadId"] as const) { + test(`${ownerField} retains the cap across successful remints`, async () => { + clearCursorOverflowRemintForTests(); + clearCursorThreadContinuityForTests(); + let attempts = 0; + let failNext = true; + const seen: string[] = []; + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { + createTransport: () => ({ + async *run(request) { + attempts++; + seen.push(request.conversationId); + if (failNext) { failNext = false; throw bareOverflowError(); } + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + }), + rekeyContextUsage: () => {}, + }); + const body = () => { + const parsed = overflowTurnBody(); + parsed._cursorConversationId = undefined; + parsed[ownerField] = `cross-request-${ownerField}`; + return parsed; + }; + await adapter.runTurn?.(body(), { headers: new Headers() }, () => {}); + expect(attempts).toBe(1); + for (let remint = 0; remint < 3; remint++) { + failNext = true; + const before = attempts; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body(), { headers: new Headers() }, event => events.push(event)); + expect(attempts - before).toBe(2); + expect(seen[seen.length - 1]).not.toBe(seen[seen.length - 2]); + expect(events.some(event => event.type === "done")).toBe(true); + } + failNext = true; + const before = attempts; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body(), { headers: new Headers() }, event => events.push(event)); + expect(attempts - before).toBe(1); + expect(events.some(event => event.type === "error")).toBe(true); + }); + } + test("conversation-only clients never gain an automatic remint allowance", async () => { + clearCursorOverflowRemintForTests(); + let attempts = 0; + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { + createTransport: () => ({ + async *run() { attempts++; throw bareOverflowError(); }, + writeClient() {}, + }), + }); + for (let turn = 0; turn < 3; turn++) { + const events: AdapterEvent[] = []; + await adapter.runTurn?.(overflowTurnBody(), { headers: new Headers() }, event => events.push(event)); + expect(attempts).toBe(turn + 1); + expect(events.some(event => event.type === "error")).toBe(true); + } + }); +}); diff --git a/tests/providers/cursor/cursor-continuity-retention.test.ts b/tests/providers/cursor/cursor-continuity-retention.test.ts new file mode 100644 index 0000000000..2d3f833e8f --- /dev/null +++ b/tests/providers/cursor/cursor-continuity-retention.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { + clearCursorOverflowRemintForTests, + CURSOR_OVERFLOW_REMINT_MAX_ENTRIES, + cursorOverflowRemintCountForTests, + markCursorOverflowSurfaced, + shouldSkipCursorOverflowRemint, + shouldSurfaceCursorOverflowFirst, +} from "../../../src/adapters/cursor/thread-continuity"; + +describe("Cursor overflow remint retention", () => { + test("bounds per-scope state", () => { + clearCursorOverflowRemintForTests(); + for (let index = 0; index < CURSOR_OVERFLOW_REMINT_MAX_ENTRIES + 20; index++) { + markCursorOverflowSurfaced(`scope-${index}`); + } + expect(cursorOverflowRemintCountForTests()).toBe(CURSOR_OVERFLOW_REMINT_MAX_ENTRIES); + clearCursorOverflowRemintForTests(); + }); + + test("read-only checks do not allocate retention entries", () => { + clearCursorOverflowRemintForTests(); + expect(shouldSurfaceCursorOverflowFirst("missing")).toBe(true); + expect(shouldSkipCursorOverflowRemint("missing")).toBe(false); + expect(cursorOverflowRemintCountForTests()).toBe(0); + }); +}); From 36625c78be4ca0ff5a94e145cdffe52a5ba9092d Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:01:36 +0900 Subject: [PATCH 05/18] test(cursor): activate remint guards after first overflow --- tests/providers/cursor/cursor-adapter.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/providers/cursor/cursor-adapter.test.ts b/tests/providers/cursor/cursor-adapter.test.ts index 82049b83f4..1f42b697dc 100644 --- a/tests/providers/cursor/cursor-adapter.test.ts +++ b/tests/providers/cursor/cursor-adapter.test.ts @@ -1017,9 +1017,12 @@ describe("Cursor overflow conversation remint", () => { stream: false, options: {}, _cursorConversationId: "cursor_overflow_tool", + _clientThreadId: "overflow-tool-result", _cursorIdentityScope: "acct-overflow-remint", }; + await adapter.runTurn?.(overflowTurnBody("overflow-tool-result"), { headers: new Headers() }, () => {}); + attempts = 0; await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); expect(attempts).toBe(1); }); @@ -1057,6 +1060,7 @@ describe("Cursor overflow conversation remint", () => { test("does not overflow-remint after non-heartbeat output was emitted", async () => { clearCursorOverflowRemintForTests(); let attempts = 0; + let emitPartial = false; const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token", @@ -1064,7 +1068,7 @@ describe("Cursor overflow conversation remint", () => { createTransport: () => ({ async *run() { attempts += 1; - yield { type: "text", text: "partial" } satisfies CursorServerMessage; + if (emitPartial) yield { type: "text", text: "partial" } satisfies CursorServerMessage; throw bareOverflowError(); }, writeClient() {}, @@ -1072,6 +1076,9 @@ describe("Cursor overflow conversation remint", () => { }); const body = overflowTurnBody("overflow-after-output"); + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + attempts = 0; + emitPartial = true; const events: AdapterEvent[] = []; await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); @@ -1113,9 +1120,11 @@ describe("Cursor overflow accounting across requests", () => { for (let remint = 0; remint < 3; remint++) { failNext = true; const before = attempts; + const priorConversation = seen[seen.length - 1]; const events: AdapterEvent[] = []; await adapter.runTurn?.(body(), { headers: new Headers() }, event => events.push(event)); expect(attempts - before).toBe(2); + expect(seen[seen.length - 2]).toBe(priorConversation); expect(seen[seen.length - 1]).not.toBe(seen[seen.length - 2]); expect(events.some(event => event.type === "done")).toBe(true); } From 321b9b1cd1e9031732f46893d6cbbc0c774cfca1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:07:33 +0900 Subject: [PATCH 06/18] fix(live): validate sideband upstream before client upgrade Co-authored-by: Kosta Milovanovic --- .../content/docs/reference/proxy-formats.md | 8 + src/server/index.ts | 368 ++++++++++++- src/server/ws-bridge.ts | 21 + structure/runtime.md | 4 + tests/server/server-live.test.ts | 493 +++++++++++++++++- 5 files changed, 865 insertions(+), 29 deletions(-) diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 8975c944cf..dedd5b83ec 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -24,6 +24,14 @@ should select among several targets. Credential-bearing model, image, video, and search requests do not automatically follow HTTP redirects, including same-origin redirects. Configure the final upstream API URL instead of a redirecting alias. A redirect does not cause the server to resend credentials or the request body to its destination. The response owner retains its existing error or relay behavior; native Responses and compact routes can return the original 3xx and `Location` to the client. Client redirect behavior is separate from this server transport policy. +## Live sideband connection failures + +The proxy completes the upstream live sideband handshake before accepting the client +WebSocket. An upstream rejection fails the upgrade with 502; a ten-second handshake timeout +returns 504. Bun does not expose the exact upstream handshake status, so an upstream 404/410 +cannot currently be forwarded precisely. A successful connection preserves the initial session +frames in order. This handshake policy is separate from the Responses WebSocket transport. + ## Endpoint overview | Client surface | Endpoint | Successful non-stream result | Successful stream or socket result | diff --git a/src/server/index.ts b/src/server/index.ts index 2cb11c1e9f..e0af64d255 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,6 +8,8 @@ import { buildResponsesWsData, sendResponseToWebSocket, sendTextFrame, + type LiveSidebandUpstreamFailure, + type LiveSidebandUpstreamHandoff, type WsData, } from "./ws-bridge"; import type { Server, ServerWebSocket } from "bun"; @@ -319,6 +321,29 @@ function withRemoteCatalogKeyId(response: Response, admission: DataPlaneAdmissio const LIVE_SIDEBAND_PENDING_MAX = 32; const LIVE_SIDEBAND_PENDING_BYTES_MAX = 1024 * 1024; const LIVE_SIDEBAND_CLOSE_FALLBACK_MS = 1_000; +/** + * Bound the pre-upgrade upstream handshake. A sideband join that cannot reach 101 + * must fail the client upgrade promptly rather than hold it open indefinitely. + */ +export const LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS = 10_000; + +/** + * Outcome of the upstream sideband handshake performed before the client upgrade. + * + * `ok: false` carries the HTTP status the client upgrade must fail with. Only an + * upgrade failure reaches codex-rs as a connect error, and only a connect error + * ends its sideband reconnect loop (`realtime_conversation/sideband.rs`: the `Err` + * arm always breaks). A 101 followed by a close is instead read as `TransportLost` + * and retried forever against the same, permanently dead call id. + */ +export type LiveSidebandUpstreamOpenResult = + | { + ok: true; + socket: WebSocket; + /** Owns capture and terminal events until the downstream relay attaches. */ + handoff: LiveSidebandUpstreamHandoff; + } + | { ok: false; status: number; code: string; message: string; socket?: WebSocket }; export function exceedsLiveSidebandFrameByteLimit(frameBytes: number): boolean { return frameBytes > MAX_WS_FRAME_BYTES; @@ -416,6 +441,48 @@ function armLiveSidebandCloseFallback(ws: ServerWebSocket, upstream: Web }, LIVE_SIDEBAND_CLOSE_FALLBACK_MS); } +function closeLiveSidebandBeforeUpgrade( + upstream: WebSocket, + release: () => void, + code = 1000, + reason = "", +): void { + // There is no downstream socket to own this transport yet. Mirror + // closeLiveSideband's bounded close contract directly: release only after a + // close event or an observed CLOSED state, never merely after requesting close. + let released = false; + let fallback: ReturnType | undefined; + const releaseOnce = (): void => { + if (released) return; + released = true; + if (fallback !== undefined) clearTimeout(fallback); + release(); + }; + upstream.addEventListener("close", releaseOnce, { once: true }); + if (upstream.readyState === WebSocket.CLOSED) { + releaseOnce(); + return; + } + fallback = setTimeout(() => { + if (upstream.readyState === WebSocket.CLOSED) { + releaseOnce(); + return; + } + try { + upstream.close(1000, "upstream close timeout"); + } catch { + /* retain ownership until CLOSED is observed */ + } + if ((upstream.readyState as number) === 3) releaseOnce(); + }, LIVE_SIDEBAND_CLOSE_FALLBACK_MS); + try { + upstream.close(code, reason); + } catch { + /* the bounded fallback retries without releasing ownership */ + } + if ((upstream.readyState as number) === 3) releaseOnce(); +} + function closeLiveSideband(ws: ServerWebSocket, code = 1000, reason = ""): void { if (ws.data.liveClosing) return; ws.data.liveClosing = true; @@ -448,29 +515,243 @@ function closeLiveSideband(ws: ServerWebSocket, code = 1000, reason = "" } } -function attachLiveSidebandUpstream( +/** + * Dial the upstream sideband and report whether its handshake reached 101. + * + * Bun's client WebSocket does not surface the upstream handshake status, so the + * result is "opened" or "failed" and nothing finer. That is sufficient for the + * property this exists to guarantee: the client is never told the relay is live + * when it is not. Frames the upstream sends before the client socket exists are + * captured and handed back by `drain`, because a session preamble such as + * `session.created` arrives immediately after the upstream opens. + */ +export function openLiveSidebandUpstream( + url: string, + headers: Record, + createWebSocket: LiveSidebandWebSocketFactory = (socketUrl, socketHeaders) => ( + new WebSocket(socketUrl, { headers: socketHeaders } as unknown as string[]) + ), + timeoutMs: number = LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS, + signal?: AbortSignal, +): Promise { + return new Promise(resolve => { + let socket: WebSocket; + try { + socket = createWebSocket(url, headers); + } catch { + resolve({ ok: false, status: 502, code: "upstream_error", message: "voice upstream connect failed" }); + return; + } + + const buffered: Array = []; + let bufferedBytes = 0; + let capturing = true; + let settled = false; + let terminalFailure: LiveSidebandUpstreamFailure | undefined; + let removeAbortListener = (): void => {}; + + const finish = (result: LiveSidebandUpstreamOpenResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + removeAbortListener(); + resolve(result); + }; + const timer = setTimeout(() => { + const failure = { status: 504, code: "upstream_timeout", message: "voice upstream did not open in time" }; + terminalFailure = failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...failure, socket }); + try { + socket.close(); + } catch { + /* ignore */ + } + }, timeoutMs); + + const failCapture = (failure: LiveSidebandUpstreamFailure): void => { + if (!capturing || terminalFailure) return; + terminalFailure = failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...failure, socket }); + try { + socket.close(1009, "sideband preamble overflow"); + } catch { + /* the terminal failure is already retained for the downstream handoff */ + } + }; + const handoff: LiveSidebandUpstreamHandoff = { + failure: () => terminalFailure, + take: () => { + capturing = false; + if (terminalFailure) return { ok: false, failure: terminalFailure }; + const frames = buffered.slice(); + buffered.length = 0; + bufferedBytes = 0; + return { ok: true, frames }; + }, + }; + + socket.addEventListener("message", event => { + if (!capturing) return; + const frameBytes = webSocketFrameBytes(event.data); + if (exceedsLiveSidebandFrameByteLimit(frameBytes)) { + failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream preamble frame is too large" }); + return; + } + if (buffered.length >= LIVE_SIDEBAND_PENDING_MAX) { + failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream sent too many preamble frames" }); + return; + } + if (exceedsLiveSidebandPendingByteLimit(bufferedBytes, frameBytes)) { + failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream preamble is too large" }); + return; + } + if (typeof event.data === "string") buffered.push(event.data); + else if (event.data instanceof ArrayBuffer) buffered.push(Buffer.from(new Uint8Array(event.data))); + else if (ArrayBuffer.isView(event.data)) { + buffered.push(Buffer.from(new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength))); + } else return; + bufferedBytes += frameBytes; + }); + socket.addEventListener("open", () => { + finish({ + ok: true, + socket, + handoff, + }); + }); + socket.addEventListener("error", () => { + const failure = { status: 502, code: "upstream_error", message: "voice upstream rejected the sideband join" }; + terminalFailure ??= failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...terminalFailure, socket }); + try { + socket.close(); + } catch { + /* the terminal failure is already retained */ + } + }); + socket.addEventListener("close", event => { + const failure = { + status: 502, + code: "upstream_error", + message: `voice upstream closed before opening (code ${event.code})`, + closeCode: event.code, + closeReason: event.reason, + }; + terminalFailure ??= failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...terminalFailure, socket }); + }); + const abortOpen = (): void => { + const failure = { status: 499, code: "request_cancelled", message: "voice sideband join was cancelled" }; + terminalFailure ??= failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...terminalFailure, socket }); + try { + socket.close(); + } catch { + /* the cancelled join no longer owns the socket */ + } + }; + if (signal) { + signal.addEventListener("abort", abortOpen, { once: true }); + removeAbortListener = () => signal.removeEventListener("abort", abortOpen); + if (signal.aborted) abortOpen(); + } + }); +} + +export function attachLiveSidebandUpstream( ws: ServerWebSocket, createWebSocket: LiveSidebandWebSocketFactory = (url, headers) => ( new WebSocket(url, { headers } as unknown as string[]) ), ): void { - const url = ws.data.liveUpstreamUrl; - if (!url) { - closeLiveSideband(ws, 1011, "missing upstream"); - return; - } + // A socket carried in from the upgrade handler already completed its handshake + // before the client was told 101. Reuse it rather than dialing a second upstream. + const preOpened = ws.data.liveUpstream; let upstream: WebSocket; - try { - // Bun accepts per-handshake headers; the DOM lib types only list protocol arrays. - upstream = createWebSocket(url, ws.data.liveUpstreamHeaders ?? {}); - } catch { - closeLiveSideband(ws, 1011, "upstream connect failed"); - return; + if (preOpened) { + upstream = preOpened; + } else { + const url = ws.data.liveUpstreamUrl; + if (!url) { + closeLiveSideband(ws, 1011, "missing upstream"); + return; + } + try { + // Bun accepts per-handshake headers; the DOM lib types only list protocol arrays. + upstream = createWebSocket(url, ws.data.liveUpstreamHeaders ?? {}); + } catch { + closeLiveSideband(ws, 1011, "upstream connect failed"); + return; + } } ws.data.liveUpstream = upstream; ws.data.liveClosing = false; ws.data.cancel = () => closeLiveSideband(ws, 1000, "client closed"); + upstream.addEventListener("close", (event) => { + if (ws.data.liveUpstream !== upstream) return; + ws.data.liveClosing = true; + finalizeLiveSideband(ws, upstream); + try { + ws.close(event.code || 1000, event.reason || ""); + } catch { + /* ignore */ + } + }); + upstream.addEventListener("error", () => { + if (ws.data.liveUpstream !== upstream) return; + closeLiveSideband(ws, 1011, "upstream error"); + }); + + if (preOpened) { + // The upstream opened before this socket existed, so its `open` event has already + // fired and the listener below will never run. Its early frames were captured for + // us; forward the capture now rather than dropping the session preamble. + const handoff = ws.data.liveUpstreamHandoff; + ws.data.liveUpstreamHandoff = undefined; + const takeover = handoff?.take(); + if (!takeover?.ok || preOpened.readyState !== WebSocket.OPEN) { + const failure = takeover && !takeover.ok ? takeover.failure : undefined; + closeLiveSideband( + ws, + failure?.closeCode ?? 1011, + failure?.closeReason ?? "upstream closed before relay attachment", + ); + return; + } + ws.data.liveOpened = true; + for (const frame of takeover.frames) { + try { + // Mirror the live message listener exactly: same ceiling, same diagnostic + // record. These frames are upstream-to-client like any other. + if (exceedsLiveSidebandFrameByteLimit(webSocketFrameBytes(frame))) { + closeLiveSideband(ws, 1009, "message too large"); + return; + } + logLiveSidebandFrame("u2c", frame); + ws.send(frame); + } catch { + closeLiveSideband(ws, 1011, "client send failed"); + return; + } + } + } + upstream.addEventListener("open", () => { if (ws.data.liveUpstream !== upstream || ws.data.liveClosing) return; ws.data.liveOpened = true; @@ -503,20 +784,6 @@ function attachLiveSidebandUpstream( closeLiveSideband(ws, 1011, "client send failed"); } }); - upstream.addEventListener("close", (event) => { - if (ws.data.liveUpstream !== upstream) return; - ws.data.liveClosing = true; - finalizeLiveSideband(ws, upstream); - try { - ws.close(event.code || 1000, event.reason || ""); - } catch { - /* ignore */ - } - }); - upstream.addEventListener("error", () => { - if (ws.data.liveUpstream !== upstream) return; - closeLiveSideband(ws, 1011, "upstream error"); - }); } // GUI static serving extracted to ./server/gui-static. Re-exported below to keep the @@ -2185,19 +2452,64 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server turnAdmissionLease.release()); + } else { + turnAdmissionLease.release(); + } + addFinalRequestLog(requestId, start, logCtx, upstreamHandshake.status); + console.error(`[live] sideband upstream handshake failed: ${upstreamHandshake.message}`); + return withCors( + formatErrorResponse(upstreamHandshake.status, upstreamHandshake.code, upstreamHandshake.message), + req, + policy, + ); + } + const handoffFailure = upstreamHandshake.handoff.failure(); + if (handoffFailure || upstreamHandshake.socket.readyState !== WebSocket.OPEN) { + closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => turnAdmissionLease.release()); + const failure = handoffFailure ?? { + status: 502, + code: "upstream_error", + message: "voice upstream closed before client upgrade", + }; + addFinalRequestLog(requestId, start, logCtx, failure.status); + return withCors(formatErrorResponse(failure.status, failure.code, failure.message), req, policy); + } addFinalRequestLog(requestId, start, logCtx, 101); if (requestServer.upgrade(req, { data: { kind: "live-sideband", + liveUpstream: upstreamHandshake.socket, liveUpstreamUrl: resolved.upstreamWsUrl, liveUpstreamHeaders: resolved.headers, + liveUpstreamHandoff: upstreamHandshake.handoff, livePending: [], livePendingBytes: 0, - liveOpened: false, + liveOpened: true, liveTurnAdmissionLease: turnAdmissionLease, } satisfies WsData, })) return undefined as unknown as Response; - turnAdmissionLease.release(); + // The upgrade was refused after the upstream had already opened; drop it. + try { + upstreamHandshake.handoff.take(); + } catch { + /* ignore */ + } + closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => turnAdmissionLease.release()); return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, policy); } diff --git a/src/server/ws-bridge.ts b/src/server/ws-bridge.ts index 5777b45a10..7b4e4c37f8 100644 --- a/src/server/ws-bridge.ts +++ b/src/server/ws-bridge.ts @@ -39,6 +39,8 @@ export interface WsData { /** Total encoded bytes retained in livePending while the upstream connects. */ livePendingBytes?: number; liveOpened?: boolean; + /** Owns captured frames and terminal state until the downstream relay attaches. */ + liveUpstreamHandoff?: LiveSidebandUpstreamHandoff; /** Once teardown starts, ignore new client frames until the upstream closes. */ liveClosing?: boolean; /** Schedules one bounded close retry without surrendering native-main ownership. */ @@ -48,6 +50,25 @@ export interface WsData { admissionLease?: AdmissionReservation>; } +export interface LiveSidebandUpstreamFailure { + status: number; + code: string; + message: string; + closeCode?: number; + closeReason?: string; +} + +export type LiveSidebandUpstreamTakeover = + | { ok: true; frames: Array } + | { ok: false; failure: LiveSidebandUpstreamFailure }; + +export interface LiveSidebandUpstreamHandoff { + /** Observe failure before the downstream upgrade without ending capture. */ + failure(): LiveSidebandUpstreamFailure | undefined; + /** Atomically ends capture and transfers buffered frames or terminal state. */ + take(): LiveSidebandUpstreamTakeover; +} + /** * Build the Responses WebSocket upgrade payload. * diff --git a/structure/runtime.md b/structure/runtime.md index 6d733bf8b5..19f7dc3c95 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -212,3 +212,7 @@ cooldowns and response-driven retry remain authoritative. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +### Live sideband handshake + +`src/server/index.ts` establishes the authorized upstream live sideband before accepting the client WebSocket upgrade. `openLiveSidebandUpstream` bounds the handshake to ten seconds and retains at most 32 frames and 1 MiB of preamble within the frame limit. `src/server/ws-bridge.ts` defines the runtime handoff carrying captured frames or terminal state. Failed handshakes return 502/504; exact upstream 404/410 status is unavailable from Bun's client WebSocket. Admission ownership lasts until upstream close/CLOSED, including failed upgrades and failed attachment. The ordinary Responses WebSocket exchange remains separate. diff --git a/tests/server/server-live.test.ts b/tests/server/server-live.test.ts index f6d4da8916..441d6bf01a 100644 --- a/tests/server/server-live.test.ts +++ b/tests/server/server-live.test.ts @@ -15,13 +15,20 @@ import { type ReadinessGate, } from "../../src/server/readiness"; import { + attachLiveSidebandUpstream, enqueueLiveSidebandPendingFrame, exceedsLiveSidebandFrameByteLimit, exceedsLiveSidebandPendingByteLimit, MAX_WS_FRAME_BYTES, + openLiveSidebandUpstream, startServer, } from "../../src/server"; -import { beginShutdownDrain, isDraining, resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import { + activeRegistryMetrics, + beginShutdownDrain, + isDraining, + resetLifecycleDrainStateForTests, +} from "../../src/server/lifecycle"; import type { OcxConfig } from "../../src/types"; import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; @@ -1739,3 +1746,487 @@ describe("GET /readyz while draining", () => { } }); }); + +/** + * A sideband join must not report 101 unless the upstream handshake actually + * succeeded. A 101 followed by a close is read by codex-rs as `TransportLost`, + * which it recovers from by rejoining the same call id indefinitely; a failed + * upgrade is a connect error instead, and that is the only outcome that ends the + * loop. These cases pin the handshake result and its client-visible consequence. + */ +class FakeUpstreamSocket { + private readonly listeners = new Map void>>(); + closed = false; + closeCalls = 0; + closeMode: "closed" | "closing" | "closing-then-close" = "closed"; + readyState = WebSocket.CONNECTING; + + addEventListener(type: string, listener: (event: { code?: number; data?: unknown; reason?: string }) => void): void { + const bucket = this.listeners.get(type) ?? []; + bucket.push(listener); + this.listeners.set(type, bucket); + } + + emit(type: string, event: { code?: number; data?: unknown; reason?: string } = {}): void { + if (type === "open") this.readyState = WebSocket.OPEN; + if (type === "close") this.readyState = WebSocket.CLOSED; + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + + close(code = 1000, reason = ""): void { + this.closed = true; + this.closeCalls += 1; + if (this.closeMode === "closing") { + this.readyState = WebSocket.CLOSING; + return; + } + if (this.closeMode === "closing-then-close") this.readyState = WebSocket.CLOSING; + this.emit("close", { code, reason }); + } +} + +function fakeSidebandClient( + upstream: FakeUpstreamSocket, + handoff: { + failure(): { status: number; code: string; message: string; closeCode?: number; closeReason?: string } | undefined; + take(): { ok: true; frames: Array } | { + ok: false; + failure: { status: number; code: string; message: string; closeCode?: number; closeReason?: string }; + }; + }, + send: (frame: string | Buffer) => void = () => {}, +) { + let releases = 0; + const ws = { + data: { + kind: "live-sideband" as const, + liveUpstream: upstream as unknown as WebSocket, + liveUpstreamHandoff: handoff, + liveOpened: true, + liveTurnAdmissionLease: { + release: () => { releases += 1; }, + }, + }, + readyState: WebSocket.OPEN, + close: () => {}, + send, + }; + return { ws, releases: () => releases }; +} + +describe("attachLiveSidebandUpstream ownership", () => { + test("transfers the actual captured preamble before subsequent live frames", async () => { + const upstream = new FakeUpstreamSocket(); + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/fixture", {}, () => upstream as unknown as WebSocket); + upstream.emit("open"); + upstream.emit("message", { data: "first" }); + upstream.emit("message", { data: new Uint8Array([2]) }); + const result = await pending; + if (!result.ok) throw new Error("expected open handshake"); + const sent: Array = []; + const client = fakeSidebandClient(upstream, result.handoff, frame => { sent.push(frame); }); + attachLiveSidebandUpstream(client.ws as never); + upstream.emit("message", { data: "third" }); + expect(sent).toEqual(["first", Buffer.from([2]), "third"]); + upstream.emit("close", { code: 1000 }); + expect(client.releases()).toBe(1); + }); + + test("retains admission through a failed takeover until a CLOSING upstream actually closes", async () => { + const upstream = new FakeUpstreamSocket(); + upstream.readyState = WebSocket.OPEN; + upstream.closeMode = "closing"; + const client = fakeSidebandClient(upstream, { + failure: () => undefined, + take: () => ({ + ok: false, + failure: { status: 502, code: "upstream_error", message: "closed", closeCode: 1008 }, + }), + }); + + attachLiveSidebandUpstream(client.ws as never); + + expect(upstream.readyState).toBe(WebSocket.CLOSING); + expect(client.releases()).toBe(0); + await Bun.sleep(1_100); + expect(upstream.closeCalls).toBe(2); + expect(client.releases()).toBe(0); + upstream.emit("close", { code: 1008, reason: "call ended" }); + expect(client.releases()).toBe(1); + upstream.emit("close", { code: 1008, reason: "duplicate close" }); + expect(client.releases()).toBe(1); + }); + + test("registers close ownership before forwarding a pre-opened preamble", () => { + const upstream = new FakeUpstreamSocket(); + upstream.readyState = WebSocket.OPEN; + upstream.closeMode = "closing-then-close"; + const client = fakeSidebandClient( + upstream, + { + failure: () => undefined, + take: () => ({ ok: true, frames: ["session.created"] }), + }, + () => { throw new Error("downstream send failed"); }, + ); + + attachLiveSidebandUpstream(client.ws as never); + + expect(upstream.closeCalls).toBe(1); + expect(upstream.readyState).toBe(WebSocket.CLOSED); + expect(client.releases()).toBe(1); + }); +}); + +describe("openLiveSidebandUpstream", () => { + test("drains the preamble captured before the client socket exists", async () => { + const socket = new FakeUpstreamSocket(); + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 1_000); + // The session preamble arrives the moment the upstream opens, before the client. + socket.emit("message", { data: "session.created" }); + socket.emit("message", { data: new Uint8Array([1, 2, 3]) }); + socket.emit("open", {}); + + const result = await pending; + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected an open upstream"); + expect(result.socket).toBe(socket); + const takeover = result.handoff.take(); + expect(takeover.ok).toBe(true); + if (!takeover.ok) throw new Error("expected a successful handoff"); + const drained = takeover.frames; + expect(drained[0]).toBe("session.created"); + expect(Buffer.isBuffer(drained[1])).toBe(true); + expect(drained[1]).toEqual(Buffer.from([1, 2, 3])); + // Drain is one-shot: the relay owns capture from here on. + expect(result.handoff.take()).toEqual({ ok: true, frames: [] }); + socket.emit("message", { data: "after-drain" }); + expect(result.handoff.take()).toEqual({ ok: true, frames: [] }); + }); + + test("fails explicitly before copying an aggregate preamble overflow", async () => { + const socket = new FakeUpstreamSocket(); + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 1_000); + const retained = new Uint8Array(1024 * 1024); + socket.emit("message", { data: retained }); + const rejectedView = new Uint8Array(retained.buffer, 0, 1); + socket.emit("message", { data: rejectedView }); + + const result = await pending; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected an overflow failure"); + expect(result.code).toBe("upstream_overflow"); + expect(socket.closed).toBe(true); + }); + + test("fails explicitly when the preamble frame-count limit is exceeded", async () => { + const socket = new FakeUpstreamSocket(); + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 1_000); + for (let index = 0; index < 33; index += 1) socket.emit("message", { data: String(index) }); + + const result = await pending; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected an overflow failure"); + expect(result.code).toBe("upstream_overflow"); + expect(socket.closed).toBe(true); + }); + + test("preserves an open-then-close terminal event until relay handoff", async () => { + const socket = new FakeUpstreamSocket(); + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 1_000); + socket.emit("open", {}); + socket.emit("close", { code: 1008 }); + + const result = await pending; + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected the completed opening handshake"); + const takeover = result.handoff.take(); + expect(takeover.ok).toBe(false); + if (takeover.ok) throw new Error("expected the terminal handoff"); + expect(takeover.failure.closeCode).toBe(1008); + }); + + test("reports failure when the upstream rejects the handshake", async () => { + const socket = new FakeUpstreamSocket(); + socket.closeMode = "closing"; + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 1_000); + socket.emit("error", {}); + + const result = await pending; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a failed handshake"); + expect(result.status).toBe(502); + expect(result.socket).toBe(socket); + expect(socket.readyState).toBe(WebSocket.CLOSING); + }); + + test("reports failure when the upstream closes before opening", async () => { + const socket = new FakeUpstreamSocket(); + const pending = openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 1_000); + socket.emit("close", { code: 1006 }); + + const result = await pending; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a failed handshake"); + expect(result.status).toBe(502); + }); + + test("cancels a pending join and closes its upstream socket", async () => { + const socket = new FakeUpstreamSocket(); + const controller = new AbortController(); + const pending = openLiveSidebandUpstream( + "ws://upstream/v1/live/x", + {}, + () => socket as unknown as WebSocket, + 1_000, + controller.signal, + ); + controller.abort(); + + const result = await pending; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a cancelled handshake"); + expect(result.code).toBe("request_cancelled"); + expect(socket.closed).toBe(true); + expect(result.socket).toBe(socket); + }); + + test("times out and drops the socket when the upstream never opens", async () => { + const socket = new FakeUpstreamSocket(); + const result = await openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => socket as unknown as WebSocket, 20); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a timeout"); + expect(result.status).toBe(504); + expect(socket.closed).toBe(true); + }); + + test("reports failure when the upstream socket cannot be constructed", async () => { + const result = await openLiveSidebandUpstream("ws://upstream/v1/live/x", {}, () => { + throw new Error("connect refused"); + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a failed handshake"); + expect(result.status).toBe(502); + expect(result.socket).toBeUndefined(); + }); +}); + +test("a failed pre-upgrade handshake retains admission until its CLOSING upstream closes", async () => { + saveConfig(forwardConfig()); + const upstream = new FakeUpstreamSocket(); + upstream.closeMode = "closing"; + const server = startServer(0, { + liveSidebandWebSocketFactory: () => { + queueMicrotask(() => upstream.emit("error", {})); + return upstream as unknown as WebSocket; + }, + }); + const activeTurnsBefore = activeRegistryMetrics().activeTurns.active; + try { + const wsUrl = new URL("/v1/realtime?call_id=rtc_failed_handshake_closing", server.url); + wsUrl.protocol = "ws:"; + const client = new WebSocket(wsUrl.toString(), { + headers: { + authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}`, + "chatgpt-account-id": "acct-123", + "openai-alpha": "quicksilver=v2", + "x-session-id": "rts_failed_handshake_closing", + }, + } as unknown as string[]); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("client never observed failed upgrade")), 5_000); + const settle = (): void => { + clearTimeout(timer); + resolve(); + }; + client.addEventListener("error", settle, { once: true }); + client.addEventListener("close", settle, { once: true }); + }); + + expect(upstream.readyState).toBe(WebSocket.CLOSING); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore + 1); + upstream.emit("close", { code: 1006, reason: "closed after handshake failure" }); + await Bun.sleep(0); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + upstream.emit("close", { code: 1006, reason: "duplicate close" }); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + } finally { + await server.stop(true); + } +}, { timeout: 10_000 }); + +test("a failed pre-upgrade handoff retains admission until its CLOSING upstream closes", async () => { + saveConfig(forwardConfig()); + const upstream = new FakeUpstreamSocket(); + upstream.closeMode = "closing"; + const server = startServer(0, { + liveSidebandWebSocketFactory: () => { + queueMicrotask(() => { + upstream.emit("open", {}); + upstream.emit("error", {}); + }); + return upstream as unknown as WebSocket; + }, + }); + const activeTurnsBefore = activeRegistryMetrics().activeTurns.active; + try { + const wsUrl = new URL("/v1/realtime?call_id=rtc_failed_handoff_closing", server.url); + wsUrl.protocol = "ws:"; + const client = new WebSocket(wsUrl.toString(), { + headers: { + authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}`, + "chatgpt-account-id": "acct-123", + "openai-alpha": "quicksilver=v2", + "x-session-id": "rts_failed_handoff_closing", + }, + } as unknown as string[]); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("client never observed failed handoff")), 5_000); + const settle = (): void => { + clearTimeout(timer); + resolve(); + }; + client.addEventListener("error", settle, { once: true }); + client.addEventListener("close", settle, { once: true }); + }); + + expect(upstream.readyState).toBe(WebSocket.CLOSING); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore + 1); + upstream.emit("close", { code: 1008, reason: "closed after failed handoff" }); + await Bun.sleep(0); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + upstream.emit("close", { code: 1008, reason: "duplicate close" }); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + } finally { + await server.stop(true); + } +}, { timeout: 10_000 }); + +test("a sideband join whose upstream handshake fails never opens the client socket", async () => { + // An upstream that refuses the upgrade: the shape OpenAI returns for a call id it + // no longer knows (`404 call_id_not_found`). + const upstream = Bun.serve({ + port: 0, + fetch(req) { + if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { + return new Response(JSON.stringify({ error: { code: "call_id_not_found" } }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + return new Response("not found", { status: 404 }); + }, + }); + + saveConfig(forwardConfig()); + + const RealWebSocket = globalThis.WebSocket; + const upstreamPort = upstream.port; + globalThis.WebSocket = class extends RealWebSocket { + constructor(url: string | URL, protocols?: string | string[] | Record) { + const parsed = new URL(String(url)); + const target = parsed.hostname === "api.openai.com" + ? `ws://127.0.0.1:${upstreamPort}${parsed.pathname}${parsed.search}` + : String(url); + super(target, protocols as string[]); + } + } as typeof WebSocket; + + const server = startServer(0); + const activeTurnsBefore = activeRegistryMetrics().activeTurns.active; + try { + const wsUrl = new URL(`/v1/realtime?call_id=rtc_dead_call`, server.url); + wsUrl.protocol = "ws:"; + const events: string[] = []; + const client = new RealWebSocket(wsUrl.toString(), { + headers: { + authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}`, + "chatgpt-account-id": "acct-123", + "openai-alpha": "quicksilver=v2", + "x-session-id": "rts_dead", + }, + } as unknown as string[]); + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("client never settled")), 15_000); + const settle = (): void => { + clearTimeout(timer); + resolve(); + }; + client.addEventListener("open", () => { + events.push("open"); + settle(); + }); + client.addEventListener("error", () => { + events.push("error"); + settle(); + }); + client.addEventListener("close", () => { + events.push("close"); + settle(); + }); + }); + + // The relay never became live, so the client must not have been told it did. + expect(events).not.toContain("open"); + expect(events.length).toBeGreaterThan(0); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + } finally { + globalThis.WebSocket = RealWebSocket; + await server.stop(true); + await upstream.stop(true); + } +}, { timeout: 20_000 }); + +test("an upstream that opens then closes before relay attachment refuses the client and releases admission", async () => { + saveConfig(forwardConfig()); + const upstream = new FakeUpstreamSocket(); + const server = startServer(0, { + liveSidebandWebSocketFactory: () => { + queueMicrotask(() => { + upstream.emit("open", {}); + upstream.emit("close", { code: 1008, reason: "call ended" }); + }); + return upstream as unknown as WebSocket; + }, + }); + const activeTurnsBefore = activeRegistryMetrics().activeTurns.active; + try { + const wsUrl = new URL("/v1/realtime?call_id=rtc_closed_handoff", server.url); + wsUrl.protocol = "ws:"; + const events: string[] = []; + const client = new WebSocket(wsUrl.toString(), { + headers: { + authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}`, + "chatgpt-account-id": "acct-123", + "openai-alpha": "quicksilver=v2", + "x-session-id": "rts_closed_handoff", + }, + } as unknown as string[]); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("client never settled")), 5_000); + const settle = (): void => { + clearTimeout(timer); + resolve(); + }; + client.addEventListener("open", () => { + events.push("open"); + settle(); + }); + client.addEventListener("error", () => { + events.push("error"); + settle(); + }); + client.addEventListener("close", () => { + events.push("close"); + settle(); + }); + }); + + expect(events).not.toContain("open"); + expect(events.length).toBeGreaterThan(0); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + } finally { + await server.stop(true); + } +}, { timeout: 10_000 }); From 7cccab33ca4a90626d6d693029f36231f02abe6a Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:45:24 +0900 Subject: [PATCH 07/18] fix(dashboard): guide hub pairing without restarting healthy clients Share browser session readiness with the shell, explicitly refresh retained resources after pairing, distinguish authentication/permission/network/shape failures, and preserve labelled stale data only for non-auth failures. Show hub identity and current-origin pairing command with copy and cancellation feedback. Local suites NOT RUN; hosted build and rendered review follow. --- devlog/_plan/260912_operations/000_plan.md | 34 ++++++++ .../260912_operations/001_roadmap_audit.md | 7 ++ devlog/_plan/260912_operations/010_update.md | 11 +++ .../_plan/260912_operations/020_listeners.md | 13 +++ devlog/_plan/260912_operations/030_totals.md | 11 +++ .../260912_operations/040_client_usage.md | 13 +++ devlog/_plan/260912_operations/050_pairing.md | 29 +++++++ .../_plan/260912_operations/060_transport.md | 7 ++ .../260912_operations/070_verification.md | 9 ++ .../src/content/docs/guides/remote-hub.md | 6 ++ .../src/content/docs/guides/web-dashboard.md | 6 ++ .../src/content/docs/ko/guides/remote-hub.md | 6 ++ .../content/docs/ko/guides/web-dashboard.md | 6 ++ gui/src/App.tsx | 21 ++++- gui/src/api.ts | 16 ++-- gui/src/connect-pairing-transport.ts | 39 +++++++-- gui/src/connect-pairing.ts | 45 +++++++--- gui/src/i18n/de.ts | 11 +++ gui/src/i18n/en.ts | 11 +++ gui/src/i18n/fr.ts | 11 +++ gui/src/i18n/ja.ts | 11 +++ gui/src/i18n/ko.ts | 11 +++ gui/src/i18n/ru.ts | 11 +++ gui/src/i18n/tr.ts | 11 +++ gui/src/i18n/zh-TW.ts | 11 +++ gui/src/i18n/zh.ts | 11 +++ gui/src/pages/Dashboard.tsx | 20 +++-- gui/src/pages/dashboard-core-poll.ts | 24 +++++- gui/src/pages/use-dashboard-data.ts | 21 ++--- gui/tests/connect-pairing.test.ts | 84 ++++++++++++++++++- gui/tests/dashboard-connection-state.test.ts | 34 ++++++++ structure/design-methodology.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/overview.md | 2 + 35 files changed, 519 insertions(+), 50 deletions(-) create mode 100644 devlog/_plan/260912_operations/000_plan.md create mode 100644 devlog/_plan/260912_operations/001_roadmap_audit.md create mode 100644 devlog/_plan/260912_operations/010_update.md create mode 100644 devlog/_plan/260912_operations/020_listeners.md create mode 100644 devlog/_plan/260912_operations/030_totals.md create mode 100644 devlog/_plan/260912_operations/040_client_usage.md create mode 100644 devlog/_plan/260912_operations/050_pairing.md create mode 100644 devlog/_plan/260912_operations/060_transport.md create mode 100644 devlog/_plan/260912_operations/070_verification.md create mode 100644 gui/tests/dashboard-connection-state.test.ts diff --git a/devlog/_plan/260912_operations/000_plan.md b/devlog/_plan/260912_operations/000_plan.md new file mode 100644 index 0000000000..f8ad96b84b --- /dev/null +++ b/devlog/_plan/260912_operations/000_plan.md @@ -0,0 +1,34 @@ +# Operations delivery roadmap + +Operators need accurate startup failures, safe update retry cleanup, readable usage, and actionable connected-client guidance. This unit carries the remaining reviewed contributions and repairs their current callers. The original stop-refusal implementation is already present on dev; it is not applied twice. + +Loop: satisfy-spec HOTL, triggered by the operations lane assignment. Goal: reviewable PRs with final-tip hosted evidence and a durable handoff. Non-goals: merging, closing originals, releases, live service/config changes, local product suites/build/typecheck/install. Scope: this managed worktree and explicitly authorized GitHub PR writes; existing credentials only. No user token/time/agent-count cap. Stop only after every disposition, acceptance row and final-tip result is recorded, or an actual unavailable external gate is documented. Memory artifact: this unit and ignored .tmp/operations/handoff.md. Escalation: real tool/access denial or scope beyond assigned issues; main owns code and decisions. Native architect role is unavailable; supported inherited design reviews plus separate A audits follow explicit user direction. + +## Work-phase map + +| ID | Document | Outcome | Dependency | +| --- | --- | --- | --- | +| roadmap | 000 + all decade docs | Docs-only source-grounded roadmap | none | +| update | 010_update.md | Retire observed exited pinned children (#4185) | roadmap | +| listeners | 020_listeners.md | Name auxiliary bind failures and malformed edits (#4236 residual) | roadmap | +| totals | 030_totals.md | Keep readable usage and disclose omissions (#4111) | roadmap | +| client-usage | 040_client_usage.md | Hub usage scoped to connected client (#4205) | roadmap; preserve totals contract if shared | +| pairing | 050_pairing.md | Hub identity and origin-specific browser authentication (#4206/#4208) | roadmap | +| transport | 060_transport.md | Reviewed local management catalog read (#4315/#4317) | roadmap | +| verification | 070_verification.md | Exact tips, hosted results, credits, handoff | all implementations | + +Independent changes use independent branches/PRs from the fetched dev baseline plus the common roadmap checkpoint. Only actual shared-code dependencies become an ordinary manual chain. No native stack registration. Intermediate auto-CI stays enabled; final cumulative tips are the acceptance unit. + +## Source disposition + +- #4170 OPEN at 4d72ef010363b80cd78f65148a5228d6797a3117, but dev contains 1ada8f5ff1 and further refusalNextStep behavior in src/lib/process-control.ts. No duplicate carry. Missing-message incident and actual scheduler behavior are not proven resolved by wording. +- #4185 OPEN at 2602f3ceca4b93237436911dcd8dffc35b3b5e57; current src/update/job.ts still uses lastChild.pid alone. +- #4111 OPEN at 2f07acb58b3e73f48cea38334f301b430a8634cd. Readable totals differ from connected-client routing. +- #4317 OPEN / CHANGES_REQUESTED at 27f577aa795f3b968c070a50eded48411bd7e985. Independent security review and own caller-complete patch required. Sensitive analysis stays in ignored scratch. +- #4236 latest public comment identifies only auxiliary listener bind diagnosis and malformed edit reporting as outstanding. #4249/#4250/#4251/#4252/#4254/#4255 are already landed and must not be replayed. + +## Evidence policy + +Local suites of every size, typecheck, builds and install: NOT RUN by explicit user instruction. Regression code goes to GitHub-hosted Cross-platform CI, whose pull_request trigger has no base filter (.github/workflows/ci.yml:3). Docs consistency uses git diff --check and file-map inspection, not claimed test coverage. Source review is not execution. Each final source SHA is paired with its actual run ID/URL/conclusion; skipped/cancelled runs are not passing proof. Every C/D records this limitation and defers behavioral acceptance to final hosted results. + +Existing tree conventions: src/cli owns commands, src/server owns listener/management boundaries, src/update owns update retries, gui/src owns React presentation, tests mirrors domains, structure/manifest.json maps owning docs. No new dependencies, service layer, or settings are needed. diff --git a/devlog/_plan/260912_operations/001_roadmap_audit.md b/devlog/_plan/260912_operations/001_roadmap_audit.md new file mode 100644 index 0000000000..d99db8768d --- /dev/null +++ b/devlog/_plan/260912_operations/001_roadmap_audit.md @@ -0,0 +1,7 @@ +# Roadmap audit outcome + +Independent design reviewers accepted the update, listener, usage, pairing and local-catalog designs after reflection. Native architect selection was unavailable; user-directed inherited model review was used without a native-role claim. Two reflection calls initially reported model capacity errors; same-handle retries completed. + +Independent A reviewer identified nested managementIngress degradation hidden by schema catch and an incomplete CLI regression path. Both were folded in and re-audited. Final verdict: GO-WITH-FIXES (blockers=0); remaining deps.managementOrigin wording corrected before lock. This audit certifies the plan only, not implementation or test behavior. + +The first cycle is docs-only. All eight roadmap files exist, source/test paths were reviewed, and git diff --cached --check exits zero. Product suites/build/typecheck/install NOT RUN by explicit instruction. Next direction: implement the observed-child cleanup slice from 010, then the independent listener slice. Final-tip hosted CI owns behavior acceptance. No retired update architecture or already-carried stop implementation is replayed. diff --git a/devlog/_plan/260912_operations/010_update.md b/devlog/_plan/260912_operations/010_update.md new file mode 100644 index 0000000000..53cb5d8719 --- /dev/null +++ b/devlog/_plan/260912_operations/010_update.md @@ -0,0 +1,11 @@ +# Pinned child retirement + +Class C4: process termination boundary. Dependency: roadmap. Reuse the existing RestartIo and restartAfterUpdate retry loop; no new process supervisor. + +MODIFY src/update/job.ts: RestartIo gains injectable spawnDetachedStartFn, preparePortForPinnedStartFn, waitForGhostListenClearFn and killProxyFn, matching existing functions. Replace Date.now in the pinned retry window with existing io.now. Replace both lastChild.pid-only kills with one closure accepting the ChildProcess: return when pid absent, exitCode non-null, or signalCode non-null; otherwise existing isAlive then kill. Capture the exact spawned child and attach once(exit) to clear lastChild only when lastChild === child. Keep healthy-probe early return. No persisted schema or serialization changes; these are process-local IO seams consumed in the retry loop only. + +MODIFY tests/update/update-job.test.ts: carry #4185 deterministic fake EventEmitter children through actual three retries, using injected clock and no real kill. Cases: successful exit, nonzero exit, signal exit, event retirement, live timeout cleanup, same-PID late old event, healthy last attempt. MODIFY tests/windows/windows-deploy-close-regressions.test.ts: replace obsolete exact PID-expression oracle with reference to behavior regression; keep wrapper ownership assertions. MODIFY structure/runtime.md to state observed-child retirement. Existing process code is otherwise unchanged. + +Exact starting patch: public PR #4185 head 2602f3ceca4b93237436911dcd8dffc35b3b5e57, reviewed source diff retained locally in .tmp/operations/pr-4185.diff. Before: numeric PID may remain after child exit. After: recorded exit/signal or matching exit event retires cleanup authority. This does not make all OS signalling atomic against PID reuse. + +Planned hosted activation checks (not yet executed): seven tests in update/update-job.test.ts exercise both cleanup sites. Local execution NOT RUN. Source check: both sites use the same child-aware closure; no unrelated test weakened. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>. diff --git a/devlog/_plan/260912_operations/020_listeners.md b/devlog/_plan/260912_operations/020_listeners.md new file mode 100644 index 0000000000..393ba74e8a --- /dev/null +++ b/devlog/_plan/260912_operations/020_listeners.md @@ -0,0 +1,13 @@ +# Auxiliary bind diagnosis and malformed edit reporting + +Class C3; dependency roadmap. Existing owners: src/server/ports.ts: isAddrInUse, src/server/index.ts: startup transaction, src/cli/index.ts: handleStart retry loop, src/config.ts: loadConfig warnings. + +MODIFY src/server/ports.ts: add AuxiliaryListenerBindError carrying listener name (unauthenticatedLoopbackListener or hub.managementIngress), port, hostname and original cause. Its message names the precise config key/address and directs correction of that effective auxiliary address (including a port-less companion). MODIFY src/server/index.ts: keep synchronous rollback of opened listeners, wrap only each auxiliary Bun.serve failure after rollback. MODIFY src/cli/index.ts: reject AuxiliaryListenerBindError before generic isAddrInUse retry logic. Public-listener EADDRINUSE retains soft/hard-pin behavior; auxiliary collisions never re-pick the public port. Fields are transient Error data, created by both auxiliary catches, consumed by CLI; no serialization. + +MODIFY src/config.ts: reuse load-time degraded-field warning family. When raw unauthenticatedLoopbackListener is present and validated value disappeared, emit field-specific warning without raw values; keep valid providers/keys and file bytes. The same pure warning is exposed by validFileConfigDiagnostics, so doctor/status also report degradation. Existing write-time loopbackListenerPortError stays strict. Whole-hub malformed blocks already warn, but nested hub.managementIngress can be silently swallowed before that check. Add a raw-versus-validated nested-ingress warning to the same owner and diagnostics. Cover string enabled and invalid port for BOTH listener blocks, preserving providers/file bytes. + +MODIFY existing tests/server/ports.test.ts and tests/server/loopback-listener-integration.test.ts : occupied loopback and management ports must report their config key and actual port, preserve cause and release every successful bind; non-EADDRINUSE errors identify bind failure without claiming the port is busy. Public conflict remains classified normally. Extend tests/config/config-load-degrade.test.ts and tests/server/loopback-listener-admission.test.ts with string enabled and out-of-range port: warning present, provider preserved, file unchanged. No local socket or service operation: tests only written, run remotely. + +MODIFY directly relevant structure/runtime.md, structure/config.md, structure/ops/service-and-sidecars.md and public hub/loopback guidance; link the canonical contract from other mapped ownership docs only where applicable. Review all conditional activation rows on hosted CI. Source inspection is not runtime proof. + +Design OPS-LIST-01..04 accepted with amendments. NEW tests/cli/cli-start-auxiliary-bind.test.ts, registered in both test-layout files, uses isolated CLI subprocess to cover soft and hard-pinned startup: failure names auxiliary key/address, exit nonzero, no public repick/wait branch. Existing management rollback fixture occupies management first then selects distinct public/loopback ports. Warning helper runs on all three load return paths plus read-only diagnostics; controls cover absent and valid-disabled entries and malformed secret-shaped input without echoing raw values. diff --git a/devlog/_plan/260912_operations/030_totals.md b/devlog/_plan/260912_operations/030_totals.md new file mode 100644 index 0000000000..71d2615772 --- /dev/null +++ b/devlog/_plan/260912_operations/030_totals.md @@ -0,0 +1,11 @@ +# Readable usage totals with explicit omissions + +Class C3; dependency roadmap. Adopt public #4111 final diff (2f07acb58b3e73f48cea38334f301b430a8634cd) after current-base and latest-review audit; preserve luvs01 credit. Source diff and metadata are in ignored .tmp/operations/pr-4111.diff/json, fetched directly from GitHub. + +MODIFY src/server/management/usage-aggregate-cache.ts: replace four oversizedRows throws with retained usageIncomplete boolean, set on full scan, OR on append, preserve in resultFrom; cache API-key snapshots with diagnostic. MODIFY api-key-usage.ts: keep readable accumulator output and attach usageIncomplete:true / usageIncompleteReason:oversized_rows instead of throwing. MODIFY logs-usage-routes.ts: serialize diagnostics on filtered and unfiltered summaries. MODIFY usage-summary-cache.ts CachedUsageSummary and oauth-account-routes.ts GET /api/keys to retain/serialize flags. Other IO/mutation errors still fail. + +MODIFY src/cli/usage-report.ts: warnings precede totals/no-match branch; incomplete no-match says skipped records may match. MODIFY gui/src/usage-summary-resource.ts shared optional diagnostic type; NEW components/usage-incomplete-notice.tsx; extend consumers Usage, dashboard overview, Models, AddProviderModal, ProviderWorkspaceShell, ApiKeysWorkspace/ListPanel and ApiKeys. Incomplete keys do not claim inactivity; warnings survive consumer caching. Add all locale keys. Full field chain: scanner oversizedRows -> retained aggregate boolean/API key snapshot -> route JSON/cache -> shared GUI/CLI input types -> every totals/ranking/key activity consumer. + +MODIFY existing tests/cli/cli-usage-report.test.ts, tests/server/api-usage.test.ts, tests/server/api-key-attribution.test.ts, tests/usage/usage-aggregate-cache.test.ts and GUI usage/custom-range/model-picker/key-workspace tests; NEW gui/tests/usage-incomplete-consumers.test.tsx. Activation: good + oversized + good rows yields readable sums and warning; append oversized sticky flag, full clean rewrite clears it, missing filter matches stays uncertain, loading/stale consumers retain warning. Do not turn IO errors into zero totals. + +MODIFY structure/gui-and-management-api.md and relevant mapped contract pointers; public management API, CLI agents and web-dashboard guides in all existing translated paths from original diff. Hosted full CI and dashboard evidence certify final tip; local suites/build/typecheck NOT RUN. This does not implement hub client-scoped CLI usage (#4205). diff --git a/devlog/_plan/260912_operations/040_client_usage.md b/devlog/_plan/260912_operations/040_client_usage.md new file mode 100644 index 0000000000..389c303f8b --- /dev/null +++ b/devlog/_plan/260912_operations/040_client_usage.md @@ -0,0 +1,13 @@ +# Connected client usage + +Class C4 for credential scope; dependency roadmap and only shared usage contract if needed. Existing src/cli/observe.ts:155 usage currently calls runtimeRequest('/api/usage'), whose owner src/cli/runtime-api.ts always resolves a local endpoint. Connected machine listener does not expose that route. + +MODIFY observe.ts usage dispatch to inspect existing client connection state before choosing the endpoint; standalone keeps runtimeRequest unchanged. Reuse the existing client-to-hub request owner and add a dedicated client-authenticated /v1/usage read, with only the enrolled client credential. Fail closed on invalid/mismatched state. Retain range/surface/provider/model and inclusive custom-window options, JSON versus human output, endpoint error messages and hub key scope. Do not send the local admin token to the hub or expose global management usage. If the existing hub endpoint supports fewer selectors, reject unsupported options explicitly until it is extended with the same authenticated scope. + +MODIFY existing client/hub API owner only where the read contract requires it; extend CLI usage and hub admission regression owners. Activation: connected client succeeds with own-key data despite local /api/usage absence; second client data excluded; revoked/bad credentials refuse; standalone still uses local management; custom-window contract preserved. Exact file map: NEW src/server/hub-usage.ts route handler and src/remote/hub-usage.ts shared response contract; MODIFY src/server/index.ts dispatch near /v1/models, src/server/auth-cors.ts configured-key identity resolver, src/client/hub-client.ts authenticated client read, src/cli/observe.ts usage dispatch, src/cli/usage-report.ts source/scope label. The route requires explicit configured-key admission even on loopback, derives apiKeyId from that identity, rejects caller-selected key IDs, uses filtered aggregation and no hub-wide summary cache. It omits private account attribution from the client DTO. Authentication/route enforcement tier: server code; caller-selected IDs cannot override the authenticated key; local admin/host control remains a residual outside client isolation. Final layer: server authorization. No claim of protection against the host owner. No local credential provisioning or running client changes. + +MODIFY public connected-client/CLI usage guide and structure/runtime.md / gui-and-management-api.md canonical scope. Any pre-disclosure details stay in scratch. Hosted regressions only; local execution NOT RUN. + +Accepted design OPS-USAGE-02/03/04. NEW tests/server/hub-usage.test.ts and tests/clients/hub-usage.test.ts with entries in scripts/test-layout/layout.json explicit and tests/fixtures/test-layout-expected.json; NEW tests/cli/cli-usage-hub.test.ts. Tests use two client keys, loopback and remote admissions, invalid state, custom window, unsupported endpoint, bad response, expired/revoked credentials. Full implementation follows source confirmation before B. + +Reflection amendments: getFilteredUsageAggregate in src/server/management/usage-aggregate-cache.ts is the aggregation owner. Client DTO preserves #4111 incomplete flags; CLI suppresses advice to remove filters for account totals because that scope never exports accounts. Public files: docs-site/src/content/docs/guides/remote-hub.md and reference/cli/agents.md. All three new tests register in scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json. diff --git a/devlog/_plan/260912_operations/050_pairing.md b/devlog/_plan/260912_operations/050_pairing.md new file mode 100644 index 0000000000..8aad40d093 --- /dev/null +++ b/devlog/_plan/260912_operations/050_pairing.md @@ -0,0 +1,29 @@ +# Pending browser authentication guidance + +Class C3; dependency roadmap. Reuse existing connected-client state and browser-auth components. Target #4206 and #4208 together because both concern the same pending local-dashboard pairing journey. + +MODIFY owning dashboard pending-auth component and bootstrap state: distinguish a reachable connected machine awaiting hub browser authentication from a stopped standalone proxy. Show configured hub identity/origin, explain that machine enrollment and browser session are separate, offer the current origin-specific existing pairing/authentication action. Preserve revoked/expired/unreachable states and their existing retry actions; do not suggest ocx start while the local runtime is reachable. Derive the next action from current origin + configured hub instead of a hardcoded localhost URL. No credentials appear in visible copy/URLs. + +MODIFY all gui/src/i18n locale dictionaries with meaningful labels. Extend existing pending-auth/dashboard tests for local origin, remote hub origin, pending, authenticated, expired/revoked and unavailable standalone; positive browser auth transitions into connected dashboard. Exact files: gui/src/App.tsx, api.ts, pages/dashboard-core-poll.ts, pages/use-dashboard-data.ts and pages/Dashboard.tsx consume a classified authentication/error state instead of a boolean. Existing connect-pairing.ts and connect-pairing-transport.ts own hub identity and origin-specific action. Define the error classification in api.ts at response ingress; consume in polling and Dashboard; reset on authenticated success and pairing completion. No persistence/serialization for this UI state. Keep cached data with stale labeling when auth fails; do not erase a known hub into standalone offline. Public hub/browser-pairing guidance is updated with the same distinction. No service restart or live auth reconfiguration. + +Hosted component suite and screenshot artifact of the rendered pending state required for final delivery; local GUI tests/build NOT RUN. Static source or mockup is not rendered application evidence. + +Accepted OPS-PAIR-01/02. Cases include browser session expiry and post-pairing refresh, local and hub origin guidance, code versus API/admin-key explanation, and operator handoff text. Prefer existing component tests; new test files only where needed. + +Reflection amendments: reuse existing api.ts SESSION_UNAVAILABLE_EVENT and App sharedSessionReady; subscribe in App, emit on terminal 401 expiry (not aborted requests), reset/read refresh on successful pairing. Do not create duplicate auth state. Poll classification and pairing errors distinguish HTTP auth refusal, transport/network, and invalid responses; aborted work does not show a failure. + +P revalidation at81f0c78d7a: same App, Dashboard, pairing and API owners remain. This independent branch starts from refresheddev; previous usage-transportD directs pairing after the user-requested source repairs. + +Concrete delta: App subscribes to SESSION_UNAVAILABLE_EVENT for the shared plane and derives readiness from hasApiSession; ignore a late notice while a newer session is present. Pairing success increments a dashboard refresh epoch and marks ready. Pass connected/authenticationPending into Dashboard. Hide protected dashboard content while authentication is pending; keep known data with a stale notice only on non-auth read failures. In fetchDashboardOverview distinguish 401(auth), 403(denied), other non-OK(request), invalid JSON/shape(invalid), and transport failure(unavailable); aborted polls propagate without publishing an error. Hook exposes failure and overview refresh without a second authentication store. Only standalone transport unavailability may show ocx start; connected/auth/invalid/request failures use relevant copy and retry. + +API wrapper emits its existing unavailable event on terminal401 only when the caller is not aborted and no newer session exists. Retain credential refresh/singleflight behavior; no new auth bypass or token persistence. Dashboard receives success epoch as a prop; useDashboardData adds it to existing useKeyedClientResource revalidation dependencies without changing resource keys or remounting. Every dashboard resource refreshes even when a settled failed/cached store survived. + +Pairing form shows target.serverOrigin, a copyable ocx gui pair --origin command for window.location.origin, instructions to run it on the hub or ask its operator, and the distinction between one-time code and API/admin keys. Reuse useCopyFeedback and existing copy labels; copy failure remains visible. Keep relay technical copy subordinate. Pairing transport gets a typed error kind (invalid-code/refused/unreachable/invalid-response), mapped to localized actionable copy while preserving pasted code; abort does not publish an error. This is process-local UI state, not a wire schema. + +Exact regressions: extend gui/tests/connect-pairing.test.ts for real App dashboard pending/authsuccess/expiry/recovery and hub/command identity; extend api-auth-deadline.test.ts for terminal notice behavior if needed; NEW gui/tests/dashboard-connection-state.test.ts for poll failure classes, cached data and no erroneous start advice. All9 locale modules get new copy. Existing Notice/buttons/tokens, variance2/motion1, dense utility layout; no decorative assets or new dependencies. Hosted built preview, inspected screenshot and browser interaction supply rendered proof later; local suites/build NOT RUN. + +Pairing lifetime precision: form keyed by target server/bootstrap identity, one AbortController per submit cancelled on unmount; transport accepts optional caller signal in addition to its existing fetch seam and checks abort before session installation. This prevents an obsolete target response from installing a session or publishing errors after its form unmounts. Keep existing request method/credential mechanics unchanged. + +A amendment: post-pairing refresh explicitly reaches each dashboard keyed resource through [apiBase, refreshEpoch] dependencies; a component remount is not treated as a cache invalidation mechanism. Regression first seeds a failed overview store, completes pairing, and requires a new authenticated health/provider read plus rendered data. + +Reflection03/05 closure:403 keeps distinct permission-denied guidance and never starts or re-pairs a running proxy merely for denied permissions. Validate HealthData status/version strings and finite nonnegative uptime; providers must be an array of objects with the required name/adapter/baseUrl strings and hasApiKey boolean, optional defaultModel string. Invalid shapes are classified invalid even with HTTP200. Unauthorized/denied content stays hidden; only nonauth read failure may show cached data with stale notice. diff --git a/devlog/_plan/260912_operations/060_transport.md b/devlog/_plan/260912_operations/060_transport.md new file mode 100644 index 0000000000..a884509d22 --- /dev/null +++ b/devlog/_plan/260912_operations/060_transport.md @@ -0,0 +1,7 @@ +# Local management catalog read + +Class C4; dependency roadmap. Scope #4315 and the current CHANGES_REQUESTED review on #4317. Public source starting points: src/cli/opencode.ts fetchOpencodeProxyModels/cmdOpencode, src/lib/admin-secrets.ts, src/lib/local-destinations.ts, associated providers/opencode-cli tests. Reuse existing transport owner after caller search; avoid an opencode-only ad hoc credential client. + +The executable security design and negative-case audit live only in ignored .tmp/operations/060_transport_private.md. That file must be completed and independently reviewed before B; no pre-disclosure reasoning is copied into public planning history. Public deliverable is the implementation, regression tests and shipped contract text only. Required review dimensions: local destination selection, redirect and proxy-environment behavior, credential separation and all current callers. Original contributor credit: Cortes Ventures . No fallback that substitutes a data credential for admin authentication. + +Hosted regression execution plus independent security source audit bind the final patch SHA. Review state is refreshed before handoff; this work cannot approve or merge the original PR. Local suites/build/typecheck/install NOT RUN. diff --git a/devlog/_plan/260912_operations/070_verification.md b/devlog/_plan/260912_operations/070_verification.md new file mode 100644 index 0000000000..e5af9101e3 --- /dev/null +++ b/devlog/_plan/260912_operations/070_verification.md @@ -0,0 +1,9 @@ +# Final tips and handoff + +Dependency: each implementation. No new behavior by default; append a separate PABCD repair cycle when actual final-tip CI failure identifies a necessary delta. + +For each independently mergeable branch: record git rev-parse HEAD, original source PR disposition, included commits, gh pr view headRefOid/baseRefName, successful native-membership read (or unknown), and gh run view for the exact Cross-platform CI run. Manual chains only when later work consumes earlier code; verify lower SHA ancestry at the final tip and record bottom-to-top order. Do not cancel auto-CI or change workflow/protection. No merge/auto-merge, closure, release or user service operation. + +A local receipt may run git diff --check and read-only hosted-result assertions; it is not a local test result. Local suites, typecheck/build/install are NOT RUN. Final behavior acceptance comes from GitHub-hosted test runs at the final SHA and independent review; author reports/old green CI are not substituted. + +Update ignored .tmp/operations/handoff.md as soon as each artifact exists. Include outstanding issue acceptance, original author trailers, unresolved maintainer objections, exact run links/conclusions and cycle ledger pointers. Publish template-complete PR bodies with truthful verification, screenshots for changed dashboard UI and no private investigation notes. Parent owns all integration decisions. diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 0db5e7bcd5..d7c257e41d 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -722,3 +722,9 @@ For a service rollback, stop the branch service and repair the prior release aga session, not a client data key. - **Outstanding revocation after disconnect:** use the hub dashboard's **Integrations → API Keys** page. It is the sole post-disconnect revocation path. + +### Pairing this browser with a hub + +Machine enrollment and browser authentication are separate. The pairing panel names the hub and displays an `ocx gui pair --origin` command for the exact origin currently open in your browser. Run that command on the hub, or send it to the hub operator and request a one-time pairing code. Paste that code into the panel; a data API key or admin token is not a pairing code. + +While browser authentication is pending, the dashboard does not recommend restarting a healthy connected client. Completing pairing refreshes the dashboard data immediately, including a previously cached authentication failure. Session expiry returns to pairing; permission denial keeps its own access-settings guidance. Other failed refreshes may show the last received data with a stale-data notice and retry action. diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 6550f404fe..67361c52fa 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -338,3 +338,9 @@ Adding **Ollama Cloud** or another catalog provider from the dashboard copies it classification into the saved provider config, so the [vision sidecar](/guides/sidecars/) is gated correctly without manual classification. ::: + +### Pairing this browser with a hub + +Machine enrollment and browser authentication are separate. The pairing panel names the hub and displays an `ocx gui pair --origin` command for the exact origin currently open in your browser. Run that command on the hub, or send it to the hub operator and request a one-time pairing code. Paste that code into the panel; a data API key or admin token is not a pairing code. + +While browser authentication is pending, the dashboard does not recommend restarting a healthy connected client. Completing pairing refreshes the dashboard data immediately, including a previously cached authentication failure. Session expiry returns to pairing; permission denial keeps its own access-settings guidance. Other failed refreshes may show the last received data with a stale-data notice and retry action. diff --git a/docs-site/src/content/docs/ko/guides/remote-hub.md b/docs-site/src/content/docs/ko/guides/remote-hub.md index c46bb91b18..a48b53df2c 100644 --- a/docs-site/src/content/docs/ko/guides/remote-hub.md +++ b/docs-site/src/content/docs/ko/guides/remote-hub.md @@ -336,3 +336,9 @@ docker compose up -d - `/v1/catalog`가 `403 origin_rejected`인데 `/readyz`가 `200`이면 데이터 리스너가 TLS 프런트엔드 뒤에서 루프백에 바인드되어 있습니다. [데이터 리스너에 TLS 붙이기](#데이터-리스너에-tls-붙이기)를 보세요. - 브라우저 로그아웃/만료는 해당 원격 세션만 끊습니다. 데이터 키와는 별개입니다. - 연결 해제 후 남은 키는 허브의 **Integrations → API Keys**에서만 폐기할 수 있습니다. + +### 이 브라우저를 허브에 인증하기 + +기기 연결과 브라우저 인증은 별개입니다. 페어링 패널에 표시된 허브에서 현재 브라우저 주소용 `ocx gui pair --origin` 명령을 실행하세요. 직접 운영하지 않는 허브라면 운영자에게 명령을 전달하고 일회용 코드를 요청하세요. 입력 칸에는 페어링 코드를 붙여 넣습니다. 데이터 API 키나 관리자 토큰을 대신 입력하지 마세요. + +인증을 기다리는 동안 정상인 클라이언트를 재시작하라고 안내하지 않습니다. 페어링을 마치면 이전 인증 오류가 캐시에 남아 있어도 대시보드를 새로 읽습니다. 세션이 만료되면 페어링 화면으로 돌아가며, 권한 거부는 별도로 안내합니다. 다른 갱신 오류에서는 마지막 데이터를 오래된 정보로 표시하고 재시도할 수 있습니다. diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 4ae2762b1a..f07c387385 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -229,3 +229,9 @@ GUI에서 OAuth 계정을 선택하면 풀 모드에서도 다음 요청에 반 이유만으로 바꾸지 않아요. 선택 계정이 429를 반환하면 풀이 꺼져 있어도 사용 가능한 다른 계정으로 자동 전환해요. 자동 선택이 저장되면 GUI의 활성 표시도 즉시 바뀌어요. 이미 서버로 보낸 요청의 인증 정보는 바꾸지 않아요. + +### 이 브라우저를 허브에 인증하기 + +기기 연결과 브라우저 인증은 별개입니다. 페어링 패널에 표시된 허브에서 현재 브라우저 주소용 `ocx gui pair --origin` 명령을 실행하세요. 직접 운영하지 않는 허브라면 운영자에게 명령을 전달하고 일회용 코드를 요청하세요. 입력 칸에는 페어링 코드를 붙여 넣습니다. 데이터 API 키나 관리자 토큰을 대신 입력하지 마세요. + +인증을 기다리는 동안 정상인 클라이언트를 재시작하라고 안내하지 않습니다. 페어링을 마치면 이전 인증 오류가 캐시에 남아 있어도 대시보드를 새로 읽습니다. 세션이 만료되면 페어링 화면으로 돌아가며, 권한 거부는 별도로 안내합니다. 다른 갱신 오류에서는 마지막 데이터를 오래된 정보로 표시하고 재시도할 수 있습니다. diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 91890ce664..6b2e1d4c32 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -15,7 +15,7 @@ import { SidebarGithubRow } from "./components/sidebar-github-row"; import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconCodex, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconX, IconRefresh} from "./icons"; import { useI18n, useT, LOCALES, localeDisplayName, type Locale, type TKey } from "./i18n/shared"; import { Select } from "./ui"; -import { configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml, logoutApiSession } from "./api"; +import { configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml, logoutApiSession, SESSION_UNAVAILABLE_EVENT } from "./api"; import { apiBaseForPlane, discoverApiTargets, isConnectedRuntime, standaloneApiTargets, type ApiTargets } from "./api-targets"; import { ConnectPairingForm } from "./connect-pairing"; import { type Page } from "./app-routing"; @@ -111,8 +111,19 @@ export default function App() { const [targetsSettled, setTargetsSettled] = useState(() => !isConnectedRuntime()); const [targetError, setTargetError] = useState(false); const [sharedSessionReady, setSharedSessionReady] = useState(() => hasApiSession("shared")); + const [sharedSessionEpoch, setSharedSessionEpoch] = useState(0); const [sessionLoggingOut, setSessionLoggingOut] = useState(false); + useEffect(() => { + const unavailable = (event: Event) => { + if ((event as CustomEvent<{ plane?: string }>).detail?.plane === "shared" && !hasApiSession("shared")) { + setSharedSessionReady(false); + } + }; + window.addEventListener(SESSION_UNAVAILABLE_EVENT, unavailable); + return () => window.removeEventListener(SESSION_UNAVAILABLE_EVENT, unavailable); + }, []); + useEffect(() => { const controller = new AbortController(); void discoverApiTargets(API_BASE, controller.signal).then(async next => { @@ -422,9 +433,13 @@ export default function App() {
{t("connection.machineUnavailable")}
)} {targets.connected && !sharedSessionReady && ( - setSharedSessionReady(true)} /> + { + setSharedSessionReady(true); + setSharedSessionEpoch(epoch => epoch + 1); + }} /> )} - {page === "dashboard" && } + {page === "dashboard" && } {page === "startup" && } {page === "providers" && } {page === "models" && } diff --git a/gui/src/api.ts b/gui/src/api.ts index 020183dd47..1d51174f9b 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -3,9 +3,8 @@ import { createBoundedFetch } from "./bounded-fetch"; import { adminTokenPromptAllowed, standaloneApiTargets, type ApiPlane, type ApiTarget, type ApiTargets } from "./api-targets"; /** - * Fired instead of the admin-token prompt when the dashboard cannot start a session on a - * deployment that has no admin token to type. The shell renders it as a notice; nothing - * blocks on it. + * Fired after an unauthorized request cannot recover a session. The shell synchronizes + * its existing readiness state; cancelled callers and newer valid sessions emit no notice. */ export const SESSION_UNAVAILABLE_EVENT = "opencodex:session-unavailable"; @@ -282,7 +281,6 @@ async function resolveTokenAfter401(plane: ApiPlane, failedToken: string | null, // of a password box the user cannot answer (#3353, #3483). if (!adminTokenPromptAllowed()) { state.promptCancelled = true; - reportSessionUnavailable(plane); return null; } const prompted = await requestAdminToken(token => verifyAdminToken(plane, token)); @@ -332,10 +330,16 @@ export function installApiAuthFetch(): void { } else clearSessionIfCurrent(classified.plane, token); const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined); const nextToken = await resolveTokenAfter401(classified.plane, token, callerSignal ?? undefined); - if (!nextToken) return response; + if (!nextToken) { + if (!callerSignal?.aborted && !hasApiSession(classified.plane)) reportSessionUnavailable(classified.plane); + return response; + } const [retryInput, retryInit] = withAuth(classified.plane, input, init, nextToken); const retry = await originalFetch(retryInput, retryInit); - if (retry.status === 401) clearSessionIfCurrent(classified.plane, nextToken); + if (retry.status === 401) { + clearSessionIfCurrent(classified.plane, nextToken); + if (!callerSignal?.aborted && !hasApiSession(classified.plane)) reportSessionUnavailable(classified.plane); + } return retry; }; } diff --git a/gui/src/connect-pairing-transport.ts b/gui/src/connect-pairing-transport.ts index fc82035085..cba4958538 100644 --- a/gui/src/connect-pairing-transport.ts +++ b/gui/src/connect-pairing-transport.ts @@ -3,6 +3,13 @@ import type { ApiTarget } from "./api-targets"; const PAIRING_CODE = /^ocx_pair_[A-Za-z0-9_-]{43}$/; +export class PairingError extends Error { + constructor(readonly kind: "invalid-code" | "refused" | "unreachable" | "request-failed" | "invalid-response") { + super(`pairing_${kind}`); + this.name = "PairingError"; + } +} + /** * Exchange a pairing code for a shared-plane session. * @@ -15,9 +22,11 @@ export async function submitConnectPairing( target: ApiTarget, grant: string, fetchImpl?: typeof fetch, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); const code = grant.trim(); - if (!PAIRING_CODE.test(code)) throw new Error("pairing_code_invalid"); + if (!PAIRING_CODE.test(code)) throw new PairingError("invalid-code"); // Resolved at CALL time, not as a default parameter. // // `installApiAuthFetch` replaces `window.fetch` with the wrapper that attaches plane @@ -26,13 +35,25 @@ export async function submitConnectPairing( // evaluated, which on the relay path is the unwrapped original, so the request went out // unauthenticated and the relay refused it. const send = fetchImpl ?? ((input, init) => window.fetch(input, init)); - const response = await send(target.bootstrapPath, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "text/html" }, - body: JSON.stringify({ grant: code }), - }); - if (!response.ok) throw new Error("pairing_refused"); - const html = await response.text(); - if (!installApiSessionFromHtml("shared", html)) throw new Error("pairing_response_invalid"); + let response: Response; + try { + response = await send(target.bootstrapPath, { + method: "POST", signal, + headers: { "Content-Type": "application/json", Accept: "text/html" }, + body: JSON.stringify({ grant: code }), + }); + } catch (error) { + if (signal?.aborted) throw error; + throw new PairingError("unreachable"); + } + if (!response.ok) { + try { await response.body?.cancel(); } catch { /* best effort */ } + throw new PairingError(response.status === 401 || response.status === 403 ? "refused" : "request-failed"); + } + let html: string; + try { html = await response.text(); } + catch (error) { if (signal?.aborted) throw error; throw new PairingError("invalid-response"); } + signal?.throwIfAborted(); + if (!installApiSessionFromHtml("shared", html)) throw new PairingError("invalid-response"); return true; } diff --git a/gui/src/connect-pairing.ts b/gui/src/connect-pairing.ts index 00e48abd7a..5d63b51af9 100644 --- a/gui/src/connect-pairing.ts +++ b/gui/src/connect-pairing.ts @@ -1,7 +1,8 @@ -import { createElement, useState, type ChangeEvent, type FormEvent } from "react"; +import { createElement, useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react"; import type { ApiTarget } from "./api-targets"; import { useT } from "./i18n/shared"; -import { submitConnectPairing } from "./connect-pairing-transport"; +import { PairingError, submitConnectPairing } from "./connect-pairing-transport"; +import { useCopyFeedback } from "./components/use-copy-feedback"; export function ConnectPairingForm({ target, @@ -13,26 +14,41 @@ export function ConnectPairingForm({ const t = useT(); const [grant, setGrant] = useState(""); const [busy, setBusy] = useState(false); - const [error, setError] = useState(false); + const [error, setError] = useState(null); + const activeRequest = useRef(null); + useEffect(() => () => activeRequest.current?.abort(), []); + const copyFeedback = useCopyFeedback(); + const command = `ocx gui pair --origin "${window.location.origin}"`; + const copied = copyFeedback.outcomeFor(command); const submit = async (event: FormEvent) => { event.preventDefault(); if (busy) return; setBusy(true); - setError(false); + setError(null); + const controller = new AbortController(); + activeRequest.current = controller; try { - await submitConnectPairing(target, grant); - onConnected(); - } catch { - setError(true); + await submitConnectPairing(target, grant, undefined, controller.signal); + if (!controller.signal.aborted) onConnected(); + } catch (failure) { + if (!controller.signal.aborted) setError(failure instanceof PairingError ? failure.kind : "unreachable"); } finally { - setBusy(false); + if (!controller.signal.aborted) setBusy(false); + if (activeRequest.current === controller) activeRequest.current = null; } }; return createElement("section", { className: "card connect-pairing", "aria-labelledby": "connect-pairing-title" }, createElement("h2", { id: "connect-pairing-title" }, t("connection.pairing.title")), - createElement("p", null, t(target.transport === "relay" ? "connection.pairing.relayWarning" : "connection.pairing.body")), + createElement("p", null, t("connection.pairing.hub"), ": ", createElement("code", null, target.serverOrigin)), + createElement("p", null, t("connection.pairing.getCode")), + createElement("pre", { style: { whiteSpace: "pre-wrap", overflowWrap: "anywhere" } }, createElement("code", null, command)), + createElement("button", { type: "button", className: "btn btn-ghost", onClick: () => copyFeedback.copy(command, command) }, + t(copied === "copied" ? "startup.copied" : "startup.copy")), + copied === "unavailable" ? createElement("p", { role: "status" }, t("prov.linkCopyUnavailable")) : null, + createElement("p", null, t("connection.pairing.askOperator")), + createElement("p", null, t("connection.pairing.notApiKey")), createElement("form", { onSubmit: submit, className: "api-form-row" }, createElement("label", { htmlFor: "connect-pairing-code", className: "field-label" }, t("connection.pairing.code")), createElement("input", { @@ -44,12 +60,17 @@ export function ConnectPairingForm({ spellCheck: false, disabled: busy, className: "input mono", - "aria-invalid": error || undefined, + "aria-invalid": Boolean(error) || undefined, "aria-describedby": error ? "connect-pairing-error" : undefined, }), createElement("button", { type: "submit", className: "btn btn-primary", disabled: busy || !grant.trim() }, t(busy ? "connection.pairing.submitting" : "connection.pairing.submit")), - error ? createElement("p", { id: "connect-pairing-error", className: "alert alert-err", role: "alert" }, t("connection.pairing.error")) : null, + error ? createElement("p", { id: "connect-pairing-error", className: "alert alert-err", role: "alert" }, + t(error === "invalid-code" ? "connection.pairing.notApiKey" + : error === "unreachable" ? "connection.pairing.networkError" + : error === "request-failed" ? "connection.pairing.requestError" + : error === "invalid-response" ? "connection.pairing.responseError" : "connection.pairing.error")) : null, ), + target.transport === "relay" ? createElement("p", { className: "text-muted" }, t("connection.pairing.relayWarning")) : null, ); } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index fdb18eb6ed..9d19757627 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2569,6 +2569,17 @@ export const de: Record = { "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", "connection.disconnect": "Disconnect from hub", "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.hub": "Hub", + "connection.pairing.getCode": "Führen Sie diesen Befehl für diesen Browser auf dem Hub aus:", + "connection.pairing.askOperator": "Falls jemand anderes den Hub betreibt, senden Sie dieser Person den Befehl und bitten Sie um einen einmaligen Kopplungscode.", + "connection.pairing.notApiKey": "Fügen Sie hier einen einmaligen Kopplungscode ein. Daten-API-Schlüssel und Admin-Token gehören nicht in dieses Feld.", + "connection.pairing.networkError": "Der Hub ist nicht erreichbar. Prüfen Sie die Verbindung und versuchen Sie es erneut; Ihr Code bleibt erhalten.", + "connection.pairing.requestError": "Der Hub konnte die Kopplungsanfrage nicht abschließen. Prüfen Sie seinen Status und versuchen Sie es erneut.", + "connection.pairing.responseError": "Der Hub hat keine gültige Browsersitzung zurückgegeben. Aktualisieren Sie den Hub oder fragen Sie den Betreiber und versuchen Sie es erneut.", + "dash.authRequired": "Zum Anzeigen dieses Dashboards ist eine Browserauthentifizierung erforderlich.", + "dash.permissionDenied": "Dieser Browser darf das Dashboard nicht lesen. Prüfen Sie die Zugriffsrechte mit dem Serverbetreiber.", + "dash.dataUnavailable": "Dashboard-Daten konnten nicht geladen werden. Prüfen Sie die Verbindung und versuchen Sie es erneut.", + "dash.staleData": "Die zuletzt empfangenen Daten werden angezeigt; sie können veraltet sein.", "connection.pairing.title": "Connect this dashboard to the hub", "connection.pairing.body": "Paste the one-time pairing code created on the hub.", "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 1847a7af7e..587c79addc 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2644,6 +2644,17 @@ export const en = { "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", "connection.disconnect": "Disconnect from hub", "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.hub": "Hub", + "connection.pairing.getCode": "Run this command on the hub for this browser:", + "connection.pairing.askOperator": "If someone else operates the hub, send them this command and ask for a one-time pairing code.", + "connection.pairing.notApiKey": "Paste a one-time pairing code here. Data API keys and admin tokens do not belong in this field.", + "connection.pairing.networkError": "Could not reach the hub. Check the connection and retry; your code is still here.", + "connection.pairing.requestError": "The hub could not complete the pairing request. Check its status and retry.", + "connection.pairing.responseError": "The hub did not return a valid browser session. Update the hub or ask its operator, then retry.", + "dash.authRequired": "Browser authentication is required to view this dashboard.", + "dash.permissionDenied": "This browser is not permitted to read the dashboard. Check access settings with the server operator.", + "dash.dataUnavailable": "Dashboard data could not be loaded. Check the connection and retry.", + "dash.staleData": "Showing the last received data; it may be out of date.", "connection.pairing.title": "Connect this dashboard to the hub", "connection.pairing.body": "Paste the one-time pairing code created on the hub.", "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index e465adbb10..b4468ccf1e 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2557,6 +2557,17 @@ export const fr: Record = { "connection.machineUnavailable": "Le plan machine local est indisponible. Les requêtes partagées n'ont pas été redirigées localement.", "connection.disconnect": "Déconnecter du hub", "connection.disconnectConfirm": "Déconnecter cette machine du hub et la redémarrer en mode autonome ?", + "connection.pairing.hub": "Hub", + "connection.pairing.getCode": "Exécutez cette commande sur le hub pour ce navigateur :", + "connection.pairing.askOperator": "Si une autre personne gère le hub, transmettez-lui cette commande et demandez un code de jumelage à usage unique.", + "connection.pairing.notApiKey": "Collez ici un code de jumelage à usage unique. Ce champ ne reçoit ni clé API de données ni jeton administrateur.", + "connection.pairing.networkError": "Impossible de joindre le hub. Vérifiez la connexion et réessayez ; votre code est conservé.", + "connection.pairing.requestError": "Le hub n’a pas pu terminer la demande de jumelage. Vérifiez son état et réessayez.", + "connection.pairing.responseError": "Le hub n’a pas renvoyé de session de navigateur valide. Mettez-le à jour ou contactez son responsable, puis réessayez.", + "dash.authRequired": "Une authentification du navigateur est nécessaire pour consulter ce tableau de bord.", + "dash.permissionDenied": "Ce navigateur n’a pas accès au tableau de bord. Vérifiez les autorisations avec le responsable du serveur.", + "dash.dataUnavailable": "Impossible de charger les données du tableau de bord. Vérifiez la connexion et réessayez.", + "dash.staleData": "Les dernières données reçues sont affichées ; elles peuvent être obsolètes.", "connection.pairing.title": "Connecter ce tableau de bord au hub", "connection.pairing.body": "Collez le code d'association à usage unique créé sur le hub.", "connection.pairing.relayWarning": "Ce code passe par le relais fixe du hub. Le relais ne peut pas viser un autre hôte.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e9a3d9f58b..da0d8cf81a 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2590,6 +2590,17 @@ export const ja: Record = { "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", "connection.disconnect": "Disconnect from hub", "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.hub": "ハブ", + "connection.pairing.getCode": "このブラウザーを認証するには、ハブで次のコマンドを実行してください。", + "connection.pairing.askOperator": "ハブの管理者が別にいる場合は、このコマンドを渡して使い捨てのペアリングコードを依頼してください。", + "connection.pairing.notApiKey": "ここには使い捨てのペアリングコードを貼り付けてください。データAPIキーや管理者トークンは入力しないでください。", + "connection.pairing.networkError": "ハブに接続できません。接続を確認して再試行してください。入力したコードは保持されています。", + "connection.pairing.requestError": "ハブがペアリング要求を完了できませんでした。状態を確認して再試行してください。", + "connection.pairing.responseError": "ハブが有効なブラウザーセッションを返しませんでした。ハブを更新するか管理者に確認して再試行してください。", + "dash.authRequired": "このダッシュボードを表示するにはブラウザーの認証が必要です。", + "dash.permissionDenied": "このブラウザーにはダッシュボードの閲覧権限がありません。サーバー管理者にアクセス設定を確認してください。", + "dash.dataUnavailable": "ダッシュボードのデータを読み込めませんでした。接続を確認して再試行してください。", + "dash.staleData": "最後に受信したデータを表示しています。最新の状態とは異なる場合があります。", "connection.pairing.title": "Connect this dashboard to the hub", "connection.pairing.body": "Paste the one-time pairing code created on the hub.", "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 67ee251970..2c4264d781 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2591,6 +2591,17 @@ export const ko: Record = { "connection.machineUnavailable": "로컬 머신 연결을 사용할 수 없습니다. 공유 요청을 로컬로 우회하지 않았습니다.", "connection.disconnect": "허브 연결 해제", "connection.disconnectConfirm": "이 머신의 허브 연결을 해제하고 독립 실행 모드로 다시 시작할까요?", + "connection.pairing.hub": "허브", + "connection.pairing.getCode": "이 브라우저를 인증하려면 허브에서 다음 명령을 실행하세요.", + "connection.pairing.askOperator": "허브를 다른 사람이 운영한다면 이 명령을 전달하고 일회용 페어링 코드를 요청하세요.", + "connection.pairing.notApiKey": "이 칸에는 일회용 페어링 코드를 붙여 넣으세요. 데이터 API 키나 관리자 토큰을 입력하는 칸이 아닙니다.", + "connection.pairing.networkError": "허브에 연결할 수 없습니다. 연결을 확인하고 다시 시도하세요. 입력한 코드는 유지됩니다.", + "connection.pairing.requestError": "허브가 페어링 요청을 완료하지 못했습니다. 허브 상태를 확인하고 다시 시도하세요.", + "connection.pairing.responseError": "허브가 유효한 브라우저 세션을 반환하지 않았습니다. 허브를 업데이트하거나 운영자에게 확인한 뒤 다시 시도하세요.", + "dash.authRequired": "이 대시보드를 보려면 브라우저 인증이 필요합니다.", + "dash.permissionDenied": "이 브라우저에는 대시보드 조회 권한이 없습니다. 서버 운영자에게 접근 설정을 확인하세요.", + "dash.dataUnavailable": "대시보드 데이터를 불러오지 못했습니다. 연결을 확인하고 다시 시도하세요.", + "dash.staleData": "마지막으로 받은 데이터를 표시합니다. 최신 상태와 다를 수 있습니다.", "connection.pairing.title": "이 대시보드를 허브에 연결", "connection.pairing.body": "허브에서 만든 일회용 페어링 코드를 붙여 넣으세요.", "connection.pairing.relayWarning": "이 코드는 고정 허브 릴레이로 교환됩니다. 릴레이 목적지는 다른 호스트로 바꿀 수 없습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 56d43fc301..35d07b7a9d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2592,6 +2592,17 @@ export const ru: Record = { "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", "connection.disconnect": "Disconnect from hub", "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.hub": "Хаб", + "connection.pairing.getCode": "Выполните эту команду на хабе для этого браузера:", + "connection.pairing.askOperator": "Если хабом управляет другой человек, передайте ему эту команду и попросите одноразовый код сопряжения.", + "connection.pairing.notApiKey": "Вставьте сюда одноразовый код сопряжения. Ключи API данных и токены администратора в это поле не вводятся.", + "connection.pairing.networkError": "Не удалось связаться с хабом. Проверьте соединение и повторите попытку; введённый код сохранён.", + "connection.pairing.requestError": "Хаб не смог завершить запрос сопряжения. Проверьте его состояние и повторите попытку.", + "connection.pairing.responseError": "Хаб не вернул действительный сеанс браузера. Обновите хаб или обратитесь к его оператору и повторите попытку.", + "dash.authRequired": "Для просмотра этой панели требуется аутентификация браузера.", + "dash.permissionDenied": "У этого браузера нет доступа к панели. Уточните настройки доступа у оператора сервера.", + "dash.dataUnavailable": "Не удалось загрузить данные панели. Проверьте соединение и повторите попытку.", + "dash.staleData": "Показаны последние полученные данные; они могут быть устаревшими.", "connection.pairing.title": "Connect this dashboard to the hub", "connection.pairing.body": "Paste the one-time pairing code created on the hub.", "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b627813bb9..cbc994e32b 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2592,6 +2592,17 @@ export const tr: Record = { "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", "connection.disconnect": "Disconnect from hub", "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.hub": "Merkez", + "connection.pairing.getCode": "Bu tarayıcı için merkezde şu komutu çalıştırın:", + "connection.pairing.askOperator": "Merkezi başka biri yönetiyorsa bu komutu ona gönderip tek kullanımlık eşleştirme kodu isteyin.", + "connection.pairing.notApiKey": "Buraya tek kullanımlık eşleştirme kodunu yapıştırın. Veri API anahtarları ve yönetici belirteçleri bu alana girilmez.", + "connection.pairing.networkError": "Merkeze ulaşılamadı. Bağlantıyı kontrol edip yeniden deneyin; kodunuz korunuyor.", + "connection.pairing.requestError": "Merkez eşleştirme isteğini tamamlayamadı. Durumunu kontrol edip yeniden deneyin.", + "connection.pairing.responseError": "Merkez geçerli bir tarayıcı oturumu döndürmedi. Merkezi güncelleyin veya yöneticisine danışıp yeniden deneyin.", + "dash.authRequired": "Bu panoyu görüntülemek için tarayıcı kimlik doğrulaması gerekiyor.", + "dash.permissionDenied": "Bu tarayıcının panoyu okuma izni yok. Erişim ayarlarını sunucu yöneticisiyle kontrol edin.", + "dash.dataUnavailable": "Pano verileri yüklenemedi. Bağlantıyı kontrol edip yeniden deneyin.", + "dash.staleData": "Son alınan veriler gösteriliyor; güncel olmayabilir.", "connection.pairing.title": "Connect this dashboard to the hub", "connection.pairing.body": "Paste the one-time pairing code created on the hub.", "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ce06556fd4..ca523fcca0 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2555,6 +2555,17 @@ export const zhTW: Record = { "connection.machineUnavailable": "本機機器平面無法使用。共享請求未改用本機資料。", "connection.disconnect": "中斷 Hub 連線", "connection.disconnectConfirm": "要中斷此機器與 Hub 的連線,並以獨立模式重新啟動嗎?", + "connection.pairing.hub": "中樞", + "connection.pairing.getCode": "請在中樞上為此瀏覽器執行以下命令:", + "connection.pairing.askOperator": "如果中樞由他人管理,請將此命令交給管理員並索取一次性配對碼。", + "connection.pairing.notApiKey": "請在此貼上一次性配對碼。此欄位不接受資料 API 金鑰或管理員權杖。", + "connection.pairing.networkError": "無法連線至中樞。請檢查連線後重試;已輸入的配對碼會保留。", + "connection.pairing.requestError": "中樞無法完成配對請求。請檢查其狀態後重試。", + "connection.pairing.responseError": "中樞未傳回有效的瀏覽器工作階段。請更新中樞或聯絡管理員後重試。", + "dash.authRequired": "檢視此儀表板需要瀏覽器身分驗證。", + "dash.permissionDenied": "此瀏覽器無權讀取儀表板。請聯絡伺服器管理員檢查存取設定。", + "dash.dataUnavailable": "無法載入儀表板資料。請檢查連線後重試。", + "dash.staleData": "正在顯示最後收到的資料,可能已過時。", "connection.pairing.title": "將此儀表板連接到 Hub", "connection.pairing.body": "貼上在 Hub 建立的一次性配對碼。", "connection.pairing.relayWarning": "此代碼透過固定 Hub 轉送交換,無法重新導向其他主機。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ae2fdfec92..a58ae6dd45 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2590,6 +2590,17 @@ export const zh: Record = { "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", "connection.disconnect": "Disconnect from hub", "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.hub": "中心", + "connection.pairing.getCode": "请在中心上为此浏览器运行以下命令:", + "connection.pairing.askOperator": "如果中心由他人管理,请将此命令发给管理员并索取一次性配对码。", + "connection.pairing.notApiKey": "请在此粘贴一次性配对码。此字段不接受数据 API 密钥或管理员令牌。", + "connection.pairing.networkError": "无法连接中心。请检查连接后重试;已输入的配对码会保留。", + "connection.pairing.requestError": "中心无法完成配对请求。请检查其状态后重试。", + "connection.pairing.responseError": "中心未返回有效的浏览器会话。请更新中心或联系管理员后重试。", + "dash.authRequired": "查看此仪表板需要浏览器身份验证。", + "dash.permissionDenied": "此浏览器无权读取仪表板。请联系服务器管理员检查访问设置。", + "dash.dataUnavailable": "无法加载仪表板数据。请检查连接后重试。", + "dash.staleData": "正在显示最后收到的数据,可能已过时。", "connection.pairing.title": "Connect this dashboard to the hub", "connection.pairing.body": "Paste the one-time pairing code created on the hub.", "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", diff --git a/gui/src/pages/Dashboard.tsx b/gui/src/pages/Dashboard.tsx index e32a671ec8..16b221834f 100644 --- a/gui/src/pages/Dashboard.tsx +++ b/gui/src/pages/Dashboard.tsx @@ -2,7 +2,7 @@ import { type ReactNode } from "react"; import { IconAlert } from "../icons"; import { Trans } from "../i18n/provider"; import { navigateHash } from "../hash-routing"; -import { EmptyState } from "../ui"; +import { EmptyState, Notice } from "../ui"; import { DashboardDialogs } from "./dashboard-dialogs"; import { DashboardModelsSection } from "./dashboard-models-section"; import { DashboardOverviewSection } from "./dashboard-overview-section"; @@ -18,19 +18,25 @@ function selectDashboardTab(next: DashboardSection) { navigateHash(dashboardHashForSection(next)); } -export default function Dashboard({ apiBase }: { apiBase: string }) { - const d = useDashboardData(apiBase); +export default function Dashboard({ apiBase, connected = false, authenticationPending = false, refreshEpoch = 0 }: { + apiBase: string; connected?: boolean; authenticationPending?: boolean; refreshEpoch?: number; +}) { + const d = useDashboardData(apiBase, refreshEpoch); const { t, error, selectedSection, providers, models, modelsLoading, modelQuery, setModelQuery, filteredGroups, expandedProviders, setExpandedProviders, } = d; - if (error) { + if (authenticationPending) return null; + const accessFailure = d.connectionFailure === "auth" || d.connectionFailure === "denied"; + if (error && (accessFailure || !d.health)) { return ( } - title={{t("dash.cannotConnect")}}> - + title={{t(d.connectionFailure === "denied" + ? "dash.permissionDenied" : d.connectionFailure === "auth" ? "dash.authRequired" : "dash.dataUnavailable")}}> + {!connected && d.connectionFailure === "unavailable" && } + ); } @@ -74,6 +80,8 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { return (
+ {error && {t("dash.staleData")} }

{t("nav.dashboard")}

diff --git a/gui/src/pages/dashboard-core-poll.ts b/gui/src/pages/dashboard-core-poll.ts index f6bb653452..5769d0080b 100644 --- a/gui/src/pages/dashboard-core-poll.ts +++ b/gui/src/pages/dashboard-core-poll.ts @@ -51,6 +51,7 @@ export type DashboardOverviewPoll = { health: HealthData | null; providers: ProviderInfo[]; error: boolean; + failure?: "auth" | "denied" | "request" | "invalid" | "unavailable"; }; /** Multi-agent extras — slower peers must not gate status/uptime/provider counts. */ @@ -252,16 +253,33 @@ export async function fetchDashboardOverview( apiBase: string, signal: AbortSignal, ): Promise { + const failed = (failure: DashboardOverviewPoll["failure"]): DashboardOverviewPoll => ({ health: null, providers: [], error: true, failure }); + let hRes: Response; + let pRes: Response; try { - const [hRes, pRes] = await Promise.all([ + [hRes, pRes] = await Promise.all([ fetch(`${apiBase}/api/system/health`, { signal }), fetch(`${apiBase}/api/providers`, { signal }), ]); + } catch (error) { + if (isAbortError(error, signal)) throw error; + return failed("unavailable"); + } + if (hRes.status === 403 || pRes.status === 403) return failed("denied"); + if (hRes.status === 401 || pRes.status === 401) return failed("auth"); + if (!hRes.ok || !pRes.ok) return failed("request"); + try { const health = await requireJson(hRes); const providers = await requireJson(pRes); + if (!health || typeof health.status !== "string" || typeof health.version !== "string" + || !Number.isFinite(health.uptime) || health.uptime < 0 + || !Array.isArray(providers) || providers.some(row => !row || typeof row.name !== "string" + || typeof row.adapter !== "string" || typeof row.baseUrl !== "string" || typeof row.hasApiKey !== "boolean" + || (row.defaultModel !== undefined && typeof row.defaultModel !== "string"))) return failed("invalid"); return { health, providers, error: false }; - } catch { - return { health: null, providers: [], error: true }; + } catch (error) { + if (isAbortError(error, signal)) throw error; + return failed("invalid"); } } diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 5bbe1210bc..410e774edc 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -135,7 +135,7 @@ function controlsCacheKey(apiBase: string): string { return `${CONTROLS_CACHE_PREFIX}${apiBase}`; } -export function useDashboardData(apiBase: string) { +export function useDashboardData(apiBase: string, refreshEpoch = 0) { const { locale, t } = useI18n(); // The hash is the source of truth for the active section (#dashboard, …). const [selectedSection, setSelectedSection] = useState(readDashboardSectionFromHash); @@ -253,7 +253,7 @@ export function useDashboardData(apiBase: string) { const startupHealthPoll = useKeyedClientResource( `dashboard-startup-health:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], (signal) => fetchStartupHealth(apiBase, signal), { pollMs: 30_000 }, ); @@ -275,7 +275,7 @@ export function useDashboardData(apiBase: string) { // Wave 1: status/uptime/providers must not wait on injection-model / usage. const overviewPoll = useKeyedClientResource( `dashboard-overview:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], (signal) => fetchDashboardOverview(apiBase, signal), { pollMs: 5000 }, ); @@ -284,14 +284,14 @@ export function useDashboardData(apiBase: string) { // Preferences that are just config — never gate on overview or injection. const maModePoll = useKeyedClientResource( `dashboard-ma-mode:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], (signal) => fetchDashboardMaMode(apiBase, signal), { pollMs: 5000 }, ); const sidecarPoll = useKeyedClientResource( `dashboard-sidecars:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], async (signal) => { const startupHealthGeneration = startupHealthGenerationRef.current; const data = await fetchDashboardSidecars(apiBase, signal, epochRefs); @@ -302,7 +302,7 @@ export function useDashboardData(apiBase: string) { const settingsPoll = useKeyedClientResource( `dashboard-settings:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], async (signal) => { const startupHealthGeneration = startupHealthGenerationRef.current; const data = await fetchDashboardSettings(apiBase, signal, epochRefs); @@ -314,14 +314,14 @@ export function useDashboardData(apiBase: string) { // Wave 2: heavier peers start after overview commits (or session seed) to cut contention. const multiAgentPoll = useKeyedClientResource( `dashboard-multi-agent:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], (signal) => fetchDashboardMultiAgent(apiBase, signal), { pollMs: 5000, enabled: overviewReady }, ); const usagePoll = useKeyedClientResource( usageSummary30dResourceKey(apiBase), - [apiBase], + [apiBase, refreshEpoch], (signal) => fetchDashboardUsage(apiBase, signal), // 30d usage is documented ~5s cold; this shared key has four subscribers, so // every one of them carries the same raised deadline (mount-order independent). @@ -330,14 +330,14 @@ export function useDashboardData(apiBase: string) { const diagnosticsPoll = useKeyedClientResource( `dashboard-diagnostics:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], (signal) => fetchProjectConfigDiagnostics(apiBase, signal), { pollMs: PROJECT_CONFIG_DIAGNOSTICS_POLL_MS, enabled: overviewReady }, ); const modelsPoll = useKeyedClientResource( `dashboard-models:${apiBase}`, - [apiBase, error], + [apiBase, error, refreshEpoch], (signal) => fetchDashboardModels(apiBase, signal), { enabled: overviewReady && !error }, ); @@ -851,6 +851,7 @@ export function useDashboardData(apiBase: string) { syncResult, syncError, projectConfigWarnings, updateOpen, updateChannel, setUpdateRestart, updateRestart, updateLoading, updateCheck, updateError, updateJob, reconnecting, error, + connectionFailure: overviewPoll.data?.failure, refreshDashboard: overviewPoll.refresh, effortCapHelpTriggerRef, updateTriggerRef, maHelpTriggerRef, shadowCallHelpTriggerRef, effortCapHelpDialogRef, updateDialogRef, maHelpDialogRef, shadowCallHelpDialogRef, filteredGroups, sidecarModels, visionModels, diff --git a/gui/tests/connect-pairing.test.ts b/gui/tests/connect-pairing.test.ts index ae68a1d1f7..e65ed3c6be 100644 --- a/gui/tests/connect-pairing.test.ts +++ b/gui/tests/connect-pairing.test.ts @@ -5,7 +5,7 @@ import { act, createElement } from "react"; test("App mounts the relay pairing form and installs only the returned shared session", async () => { const keys = ["window", "document", "navigator", "sessionStorage", "localStorage", "fetch", "confirm", "alert", "IS_REACT_ACT_ENVIRONMENT", "__APP_VERSION__"] as const; const previous = Object.fromEntries(keys.map(key => [key, Reflect.get(globalThis, key)])); - const win = new Window({ url: "http://localhost/#usage" }); + const win = new Window({ url: "http://localhost/#dashboard" }); Object.defineProperties(globalThis, { window: { configurable: true, value: win }, document: { configurable: true, value: win.document }, @@ -33,6 +33,9 @@ test("App mounts the relay pairing form and installs only the returned shared se document.head.append(meta); } + let authorized = false; + let rejectSession = false; + let authenticatedHealthReads = 0; let pairingRequest: { method: string; body: string; headers: Headers } | null = null; const sessionHtml = [ '', @@ -51,9 +54,21 @@ test("App mounts the relay pairing form and installs only the returned shared se hubReachability: "unknown", }); if (url.pathname === "/api/machine/hub-relay/opencodex-session" && init?.method === "POST") { + authorized = true; rejectSession = false; pairingRequest = { method: init.method, body: String(init.body), headers }; return new Response(sessionHtml, { headers: { "Content-Type": "text/html" } }); } + if (url.pathname.endsWith("/opencodex-session")) return new Response(null, { status: 401 }); + if (url.pathname.endsWith("/api/system/health")) { + if (!authorized || rejectSession) return new Response(null, { status: 401 }); + expect(headers.get("x-opencodex-api-key")).toBe("ocx_session_hub"); + authenticatedHealthReads++; + return Response.json({ status: "ok", version: "0.0.0-test", uptime: 30 }); + } + if (url.pathname.endsWith("/api/providers")) return Response.json([ + { name: "fixture", adapter: "openai-chat", baseUrl: "https://fixture.example.test", hasApiKey: false }, + ]); + if (url.pathname.endsWith("/api/models")) return Response.json([]); if (url.pathname === "/healthz") return Response.json({ version: "0.0.0-test" }); if (url.pathname.endsWith("/api/usage")) return Response.json({ range: "30d", surface: "all", since: null, generatedAt: Date.now(), @@ -85,6 +100,11 @@ test("App mounts the relay pairing form and installs only the returned shared se installApiAuthFetch(); const { default: App } = await import("../src/App"); Object.defineProperty(globalThis, "fetch", { configurable: true, value: win.fetch }); + const resources = await import("../src/client-resource"); + resources.clearClientResourceStoresForTests(); + resources.setClientResourceData("dashboard-overview:http://localhost/api/machine/hub-relay", { + health: null, providers: [], error: true, failure: "auth", + }); const { createRoot } = await import("react-dom/client"); const root = createRoot(container); try { @@ -94,6 +114,10 @@ test("App mounts the relay pairing form and installs only the returned shared se if (Date.now() >= deadline) throw new Error("pairing form did not mount from App"); await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 10)); }); } + expect(container.textContent).toContain("https://hub.example.test"); + expect(container.textContent).toContain('ocx gui pair --origin "http://localhost"'); + expect(container.textContent).not.toContain("ocx start"); + expect(container.querySelector(".dashboard-workspace-shell")).toBeNull(); const input = container.querySelector("#connect-pairing-code") as HTMLInputElement; Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input, `ocx_pair_${"a".repeat(43)}`); await act(async () => { input.dispatchEvent(new win.Event("input", { bubbles: true })); }); @@ -108,8 +132,36 @@ test("App mounts the relay pairing form and installs only the returned shared se expect(pairingRequest?.body).toBe(JSON.stringify({ grant: `ocx_pair_${"a".repeat(43)}` })); expect(pairingRequest?.headers.get("x-opencodex-machine-session")).toBe("ocx_session_machine"); expect(pairingRequest?.headers.get("x-opencodex-api-key")).toBeNull(); + const refreshDeadline = Date.now() + 5_000; + while (authenticatedHealthReads === 0 || !container.querySelector(".dashboard-workspace-shell")) { + if (Date.now() >= refreshDeadline) throw new Error("pairing did not refresh the retained failed dashboard store"); + await act(async () => { await new Promise(resolve => setImmediate(resolve)); }); + } + await act(async () => { + resources.setClientResourceData("dashboard-overview:http://localhost/api/machine/hub-relay", { + health: null, providers: [], error: true, failure: "unavailable", + }); + }); + expect(container.querySelector(".dashboard-workspace-shell")).not.toBeNull(); + expect(container.textContent).toContain("Showing the last received data"); + expect(container.textContent).not.toContain("ocx start"); + await act(async () => { + resources.setClientResourceData("dashboard-overview:http://localhost/api/machine/hub-relay", { + health: null, providers: [], error: true, failure: "denied", + }); + }); + expect(container.querySelector(".dashboard-workspace-shell")).toBeNull(); + expect(container.textContent).toContain("not permitted to read the dashboard"); + expect(container.textContent).not.toContain("ocx start"); + rejectSession = true; + await act(async () => { expect((await fetch("http://localhost/api/machine/hub-relay/api/system/health")).status).toBe(401); }); + expect(container.querySelector("#connect-pairing-code")).not.toBeNull(); + expect(container.querySelector(".dashboard-workspace-shell")).toBeNull(); + expect(container.textContent).not.toContain("ocx start"); + } finally { await act(async () => { root.unmount(); }); + resources.clearClientResourceStoresForTests(); container.remove(); win.close(); for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: previous[key] }); @@ -161,3 +213,33 @@ test("a refused pairing renders an accessible error without clearing the pasted for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: previous[key] }); } }); + + +test("a cancelled pairing body cannot install its obsolete session", async () => { + const { submitConnectPairing } = await import("../src/connect-pairing-transport"); + const controller = new AbortController(); + let release!: (text: string) => void; + let reading!: () => void; + const started = new Promise(resolve => { reading = resolve; }); + const response = new Response(""); + response.text = () => new Promise(resolve => { release = resolve; reading(); }); + const pending = submitConnectPairing({ id: "shared", baseUrl: "https://hub.example.test", + serverOrigin: "https://hub.example.test", bootstrapPath: "https://hub.example.test/opencodex-session", transport: "direct" }, + `ocx_pair_${"a".repeat(43)}`, (async () => response) as typeof fetch, controller.signal); + await started; + controller.abort(); + release(''); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); +}); + +test("pairing reports refusal, server failure and network failure separately", async () => { + const { submitConnectPairing } = await import("../src/connect-pairing-transport"); + const target = { id: "shared" as const, baseUrl: "https://hub.example.test", serverOrigin: "https://hub.example.test", + bootstrapPath: "https://hub.example.test/opencodex-session", transport: "direct" as const }; + for (const [status, kind] of [[403, "refused"], [503, "request-failed"]] as const) { + await expect(submitConnectPairing(target, `ocx_pair_${"a".repeat(43)}`, + (async () => new Response(null, { status })) as typeof fetch)).rejects.toMatchObject({ kind }); + } + await expect(submitConnectPairing(target, `ocx_pair_${"a".repeat(43)}`, + (async () => { throw new Error("network"); }) as typeof fetch)).rejects.toMatchObject({ kind: "unreachable" }); +}); diff --git a/gui/tests/dashboard-connection-state.test.ts b/gui/tests/dashboard-connection-state.test.ts new file mode 100644 index 0000000000..7597d9bc04 --- /dev/null +++ b/gui/tests/dashboard-connection-state.test.ts @@ -0,0 +1,34 @@ +import { afterEach, expect, test } from "bun:test"; +import { fetchDashboardOverview } from "../src/pages/dashboard-core-poll"; + +const originalFetch = globalThis.fetch; +const health = { status: "ok", version: "1.0.0", uptime: 10 }; +const providers = [{ name: "fixture", adapter: "openai-chat", baseUrl: "https://fixture.example.test", hasApiKey: false }]; +afterEach(() => { globalThis.fetch = originalFetch; }); + +test.each([[401, "auth"], [403, "denied"], [500, "request"]] as const)("HTTP %i has its own dashboard failure meaning", async (status, failure) => { + globalThis.fetch = (async () => new Response(null, { status })) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toMatchObject({ error: true, failure }); +}); + +test.each([{}, { ...health, uptime: "ten" }, { ...health, uptime: -1 }])("malformed health JSON is not treated as a stopped proxy", async invalid => { + globalThis.fetch = (async input => Response.json(String(input).endsWith("/api/providers") ? providers : invalid)) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toMatchObject({ failure: "invalid" }); +}); + +test.each([{}, [null], [{ name: "fixture" }]])("malformed providers are rejected before caching", async invalid => { + globalThis.fetch = (async input => Response.json(String(input).endsWith("/api/providers") ? invalid : health)) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toMatchObject({ failure: "invalid" }); +}); + +test("valid dashboard response retains the existing success shape", async () => { + globalThis.fetch = (async input => Response.json(String(input).endsWith("/api/providers") ? providers : health)) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toEqual({ health, providers, error: false }); +}); + +test("transport failure is distinct from cancelled polling", async () => { + globalThis.fetch = (async () => { throw new TypeError("network unavailable"); }) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toMatchObject({ failure: "unavailable" }); + const controller = new AbortController(); controller.abort(); + await expect(fetchDashboardOverview("", controller.signal)).rejects.toThrow(); +}); diff --git a/structure/design-methodology.md b/structure/design-methodology.md index 51a2f158bc..5d210c1bbc 100644 --- a/structure/design-methodology.md +++ b/structure/design-methodology.md @@ -39,3 +39,5 @@ surfaces, run through all 3 stages in order. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +The pairing panel names the hub, offers an origin-specific command to run on that hub, and separates one-time codes from data/admin credentials. Copy outcomes and request failures use existing notice/button patterns. Failed authentication never masquerades as a stopped connected process. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index a3c61e1f7d..a32de28100 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -537,3 +537,5 @@ advances the observation clock, so a retained older row cannot defer evaluation Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +The connected browser shell reuses `SESSION_UNAVAILABLE_EVENT` and its shared-session readiness state. Terminal 401 recovery failure exposes pairing without a restart instruction; a newer session or aborted request cannot publish an unavailable notice. Successful pairing changes dashboard resource revalidation dependencies, so retained failed stores are explicitly refreshed. Dashboard reads distinguish authentication, permission denial, request failure, invalid payload and transport failure; protected data is hidden for authentication/denial, while other failed refreshes label retained data as stale. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 78d0e038ec..873fdbaca8 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -312,3 +312,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Hub/browser pairing instructions distinguish machine enrollment, session authentication, permission denial and network failure. The hosted dashboard preview is the render artifact used to review these states. diff --git a/structure/overview.md b/structure/overview.md index da3f2dc473..0ffa16de2d 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -106,3 +106,5 @@ would pass while the rule was violated. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +Connected-browser pairing and dashboard failure meanings follow the [management UI contract](gui-and-management-api.md#dashboard-surfaces); machine enrollment alone does not authenticate a browser. From 673ee8c170d9c3ac9c6d1e0fea30d020981928d7 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:06:28 +0900 Subject: [PATCH 08/18] fix(openai-chat): normalize oversized inline images before serialization Carry #4119 with current structured-output fields preserved, retained-byte accounting, and conditional-async MiMo delegation. Co-authored-by: DamnUi --- .../src/content/docs/guides/providers.md | 4 + scripts/test-layout/layout.json | 1 + src/adapters/anthropic-image-normalize.ts | 32 +- src/adapters/base.ts | 4 +- src/adapters/mimo-free.ts | 2 +- src/adapters/openai-chat-images.ts | 101 +++++ src/adapters/openai-chat.ts | 359 ++++++++--------- structure/adapters/registry.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/ops/docs-and-release.md | 2 + structure/overview.md | 2 + structure/providers/chat-compat.md | 2 + structure/providers/cursor.md | 2 + structure/runtime.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 2 + structure/transports/streaming-health.md | 4 + .../anthropic-image-normalize.test.ts | 51 +++ .../openai-chat-image-normalization.test.ts | 364 ++++++++++++++++++ .../openai/openai-chat-native-policy.test.ts | 50 +++ tests/fixtures/test-layout-expected.json | 1 + 21 files changed, 810 insertions(+), 181 deletions(-) create mode 100644 src/adapters/openai-chat-images.ts create mode 100644 tests/adapters/openai/openai-chat-image-normalization.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index a093f22dc6..056e7101f9 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1038,3 +1038,7 @@ no quota bars rather than a fabricated one, and windows the plan does not report absent instead of rendering as 0%. A provider using a non-canonical `baseUrl` is never sent the key for this probe. + +## Large inline images on Chat providers + +Translated OpenAI-compatible Chat requests shrink inline images when their combined base64 data exceeds 3.5 MiB. Older images lose detail first. This is a best-effort image budget, so large text, schemas, or images that cannot be processed may still exceed an upstream request limit. Remote image URLs are not downloaded, and images that cannot be shrunk remain attached. Native Chat passthrough keeps its original image bytes. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 8f40c0714d..49d18e3212 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -944,6 +944,7 @@ "openai-chat-dangling-toolcalls.test.ts": "adapters/openai", "openai-chat-eof.test.ts": "adapters/openai", "openai-chat-hardening.test.ts": "adapters/openai", + "openai-chat-image-normalization.test.ts": "adapters/openai", "openai-chat-invalid-tool-call-diagnostics.test.ts": "adapters/openai", "openai-chat-model-suffix.test.ts": "adapters/openai", "openai-chat-path-override.test.ts": "adapters/openai", diff --git a/src/adapters/anthropic-image-normalize.ts b/src/adapters/anthropic-image-normalize.ts index cd5f52b16d..81cd86b8ff 100644 --- a/src/adapters/anthropic-image-normalize.ts +++ b/src/adapters/anthropic-image-normalize.ts @@ -68,6 +68,14 @@ export interface NormalizeTarget { mediaType: string; replace(data: string, mediaType: string): void; drop(note: string): void; + /** + * True when `drop` leaves the original bytes on the wire instead of removing or + * textifying them (openai-chat, which has no downstream guard that could re-attach a + * dropped image). The core normally stops counting a dropped target, which is correct + * only when the bytes actually leave. Here they do not, so those bytes keep counting + * toward the budget and the demotion loop keeps shrinking the images it still can. + */ + retainsBytesOnDrop?: boolean; } export interface NormalizeTargetsOptions extends NormalizeOptions { @@ -127,11 +135,17 @@ export async function normalizeImageTargets(targets: NormalizeTarget[], options: if (newestFirstIndex >= processLimit) continue; if (b64.length > MAX_INPUT_BASE64_LENGTH) { target.drop(BOMB_TEXT); + if (target.retainsBytesOnDrop) { + entries[i] = { target, sourceB64: b64, sourceMedia: target.mediaType.toLowerCase(), pos: TERMINAL_POS, size: b64.length, done: true }; + } continue; } const dims = sniffImageDimensions(b64); if (dims && dims.width * dims.height > MAX_INPUT_PIXELS) { target.drop(BOMB_TEXT); + if (target.retainsBytesOnDrop) { + entries[i] = { target, sourceB64: b64, sourceMedia: target.mediaType.toLowerCase(), pos: TERMINAL_POS, size: b64.length, done: true }; + } continue; } const sourceMedia = target.mediaType.toLowerCase(); @@ -139,6 +153,9 @@ export async function normalizeImageTargets(targets: NormalizeTarget[], options: const result = await processAt(b64, pos, sourceMedia, encode, validate); if (result.kind === "failed") { target.drop(UNDECODABLE_TEXT); + if (target.retainsBytesOnDrop) { + entries[i] = { target, sourceB64: b64, sourceMedia, pos: TERMINAL_POS, size: b64.length, done: true }; + } continue; } let size = b64.length; @@ -178,8 +195,14 @@ export async function normalizeImageTargets(targets: NormalizeTarget[], options: const result = await processAt(entry.sourceB64, entry.pos + 1, entry.sourceMedia, encode, validate); if (result.kind === "failed") { entry.target.drop(UNDECODABLE_TEXT); - sum -= entry.size; - entries[entries.indexOf(entry)] = null; + if (entry.target.retainsBytesOnDrop) { + // Bytes stay on the wire, so they stay in the total; mark it terminal so the + // loop moves on to a target it can still shrink instead of retrying this one. + entry.done = true; + } else { + sum -= entry.size; + entries[entries.indexOf(entry)] = null; + } continue; } let newSize = entry.size; @@ -202,6 +225,11 @@ export async function normalizeImageTargets(targets: NormalizeTarget[], options: const e = entries[i]; if (!e) continue; e.target.drop(OVERFLOW_DROP_TEXT); + if (e.target.retainsBytesOnDrop) { + // The drop left the bytes in place, so they still count and dropping another + // copy of this target would not help. Move on to one that can actually leave. + continue; + } sum -= e.size; entries[i] = null; } diff --git a/src/adapters/base.ts b/src/adapters/base.ts index f5a22b2969..6ef83cd672 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -15,8 +15,8 @@ export interface IncomingMeta { providerFetch?: typeof globalThis.fetch; /** * Image-normalization ladder bias for upstream-413 tightened retries: every image - * starts one tier lower (devlog/260714_image_normalization_pipeline/030). Only the - * anthropic adapter consumes it; others ignore it. + * starts one tier lower (devlog/260714_image_normalization_pipeline/030). Consumed by + * the anthropic and openai-chat adapters; others ignore it. */ imageTierBias?: number; } diff --git a/src/adapters/mimo-free.ts b/src/adapters/mimo-free.ts index d394423cb1..6500e023b5 100644 --- a/src/adapters/mimo-free.ts +++ b/src/adapters/mimo-free.ts @@ -225,7 +225,7 @@ export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdap // Let the base adapter build the wire body (handles reasoning, tools, etc.) // but override the URL and headers after. - const baseReq = base.buildRequest(parsed, incoming) as AdapterRequest; + const baseReq = await base.buildRequest(parsed, incoming); const baseBody = JSON.parse(baseReq.body as string) as unknown; const markedBody = injectMimoSystemMarker(baseBody); diff --git a/src/adapters/openai-chat-images.ts b/src/adapters/openai-chat-images.ts new file mode 100644 index 0000000000..0aabecaa8b --- /dev/null +++ b/src/adapters/openai-chat-images.ts @@ -0,0 +1,101 @@ +import { parseDataUrl } from "./image"; +import { + normalizeImageTargets, + type NormalizeOptions, + type NormalizeTarget, +} from "./anthropic-image-normalize"; + +/** + * Best-effort base64 image budget for translated Chat requests. This leaves room for + * other request fields but is not a guarantee that the complete body fits an upstream + * limit. Remote URLs are never fetched by request construction. + */ +export const OPENAI_CHAT_IMAGE_BASE64_BUDGET = 3_670_016; // 3.5MiB + +export interface NormalizeOpenAIChatImagesOptions + extends Pick {} + +/** Whether `value` is a plain object, so message and part shapes can be walked safely. */ +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** + * Walk every well-formed `image_url` part in a Chat Completions message array, ignoring + * malformed shapes rather than throwing on them. Returning false from `visit` stops the walk. + */ +function forEachImagePart( + messages: unknown, + visit: (imageUrl: Record, url: string) => boolean | void, +): void { + if (!Array.isArray(messages)) return; + for (const message of messages) { + if (!isRecord(message) || !Array.isArray(message.content)) continue; + for (const part of message.content) { + if (!isRecord(part) || part.type !== "image_url" || !isRecord(part.image_url)) continue; + const imageUrl = part.image_url; + if (typeof imageUrl.url !== "string") continue; + if (visit(imageUrl, imageUrl.url) === false) return; + } + } +} + +/** + * Whether this turn carries inline image bytes worth normalizing. The adapter uses this + * to stay synchronous for text-only turns, which is every turn on most providers. + */ +export function hasShrinkableOpenAIChatImages(messages: unknown): boolean { + let total = 0; + let found = false; + forEachImagePart(messages, (_imageUrl, url) => { + const source = parseDataUrl(url); + if (!source) return; + total += source.base64.length; + if (total > OPENAI_CHAT_IMAGE_BASE64_BUDGET) { + found = true; + return false; + } + }); + return found; +} + +/** + * Normalize image_url parts in already-built Chat Completions messages, in place. + * + * The drop callback deliberately keeps the original URL. The shared normalizer calls + * drop for corrupt or decode-bomb inputs, and this wire has no downstream guard that + * would re-attach a dropped image, so dropping here would silently lose a user's + * screenshot. Terminal-size overflow uses overflowAction "none" for the same reason: + * an image floored at 320px stays attached rather than being removed. + */ +export async function normalizeOpenAIChatImages( + messages: unknown, + options: NormalizeOpenAIChatImagesOptions = {}, +): Promise { + const targets: NormalizeTarget[] = []; + forEachImagePart(messages, (imageUrl, url) => { + const source = parseDataUrl(url); + if (!source) return; + targets.push({ + base64: source.base64, + mediaType: source.mediaType, + replace: (data: string, mediaType: string) => { + imageUrl.url = `data:${mediaType};base64,${data}`; + }, + drop: () => { + // Preserve the original image URL when it cannot be normalized. + }, + // The drop above is a no-op, so these bytes are still on the wire and must keep + // counting against the budget. Without this the core would stop counting them and + // the demotion loop could stop early, shipping a body that is still oversized. + retainsBytesOnDrop: true, + }); + }); + if (targets.length === 0) return; + + await normalizeImageTargets(targets, { + budget: OPENAI_CHAT_IMAGE_BASE64_BUDGET, + overflowAction: "none", + ...options, + }); +} diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index e338d845ad..6c6147c843 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1,4 +1,5 @@ -import type { AdapterRequest, ProviderAdapter } from "./base"; +import { hasShrinkableOpenAIChatImages, normalizeOpenAIChatImages } from "./openai-chat-images"; +import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types"; import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types"; import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort"; @@ -1462,206 +1463,212 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd formatErrorBody: formatOpenAIChatErrorBody, - buildRequest(parsed: OcxParsedRequest) { + buildRequest(parsed: OcxParsedRequest, incoming?: IncomingMeta) { lastRequestedModelId = parsed.modelId; const { url, headers, hasCredential } = openAIChatTransport(provider); const messages = frameAgentRouterMessages(provider.baseUrl, messagesToChatFormat(parsed, provider)); - const tools = toolsToChatFormatForProvider(parsed, provider); - const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider); + const finish = (): AdapterRequest => { + const tools = toolsToChatFormatForProvider(parsed, provider); + const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider); - const body: Record = { - model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(parsed.modelId) : parsed.modelId, - messages, - stream: parsed.stream, - }; - // A policy-produced canonical decision has already passed capability validation. Without - // that decision, a canonical caller value still requires an explicit true capability; - // unclassified Chat routes remain behind the caller-forwarding opt-in. - const serviceTier = parsed.options.serviceTier; - const tierDecision = parsed.options.tierDecision; - const canSerializeServiceTier = canSerializeOpenAIChatServiceTier( - provider, - parsed.modelId, - serviceTier, - tierDecision, - ); - if (canSerializeServiceTier && serviceTier !== undefined) { - body.service_tier = serviceTier; - } - if (modelInList(provider.reasoningSplitModels, parsed.modelId)) body.reasoning_split = true; - const maxTokens = resolveMaxTokens(provider, parsed); - const openRouterRouting = resolveOpenRouterRouting(provider, parsed.modelId); - if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting); - const vercelRouting = resolveVercelGatewayRouting(provider, parsed.modelId); - if (vercelRouting) body.provider = vercelGatewayProviderPayload(vercelRouting); - if (tools) body.tools = tools; - if (tools && toolChoice !== undefined) { - body.tool_choice = modelInList(provider.autoToolChoiceOnlyModels, parsed.modelId) - ? (toolChoice === "none" ? "none" : "auto") - : toolChoice; - } - if (maxTokens !== undefined) body.max_tokens = maxTokens; - if (parsed.options.temperature !== undefined && !modelInList(provider.noTemperatureModels, parsed.modelId)) { - body.temperature = parsed.options.temperature; - } - if (parsed.options.topP !== undefined && !modelInList(provider.noTopPModels, parsed.modelId)) { - body.top_p = parsed.options.topP; - } - if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences; - const reasoningDisabled = modelInList(provider.noReasoningModels, parsed.modelId); - // Some gateways accept a reasoning-effort field on a plain turn but reject the - // effort + tools combination. `noReasoningModels` would fix that only by - // stripping reasoning everywhere, costing the model its whole picker. This keeps - // the ladder advertised and drops the wire field for tool-bearing requests only. - const omitReasoningEffortWithTools = !!tools - && modelInList(provider.omitReasoningEffortWithToolsModels, parsed.modelId); - const reasoningEffort = omitReasoningEffortWithTools - ? undefined - : mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); - const nativeOpenAI = isNativeOpenAIChatTarget(provider); - let reasoningLog: AdapterRequest["reasoningLog"]; - if (!reasoningDisabled && !omitReasoningEffortWithTools && provider.reasoningWireFormat === "gateway-object" && parsed.options.reasoning === "none") { - if (nativeOpenAI) { - body.reasoning_effort = "none"; - reasoningLog = { - effectiveEffort: "none", - wireField: "reasoning_effort", - wireValue: "none", - }; - } else { - body.reasoning = { enabled: false }; - reasoningLog = { - effectiveEffort: "none", - wireField: "reasoning.enabled", - wireValue: false, - }; + const body: Record = { + model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(parsed.modelId) : parsed.modelId, + messages, + stream: parsed.stream, + }; + // A policy-produced canonical decision has already passed capability validation. Without + // that decision, a canonical caller value still requires an explicit true capability; + // unclassified Chat routes remain behind the caller-forwarding opt-in. + const serviceTier = parsed.options.serviceTier; + const tierDecision = parsed.options.tierDecision; + const canSerializeServiceTier = canSerializeOpenAIChatServiceTier( + provider, + parsed.modelId, + serviceTier, + tierDecision, + ); + if (canSerializeServiceTier && serviceTier !== undefined) { + body.service_tier = serviceTier; + } + if (modelInList(provider.reasoningSplitModels, parsed.modelId)) body.reasoning_split = true; + const maxTokens = resolveMaxTokens(provider, parsed); + const openRouterRouting = resolveOpenRouterRouting(provider, parsed.modelId); + if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting); + const vercelRouting = resolveVercelGatewayRouting(provider, parsed.modelId); + if (vercelRouting) body.provider = vercelGatewayProviderPayload(vercelRouting); + if (tools) body.tools = tools; + if (tools && toolChoice !== undefined) { + body.tool_choice = modelInList(provider.autoToolChoiceOnlyModels, parsed.modelId) + ? (toolChoice === "none" ? "none" : "auto") + : toolChoice; + } + if (maxTokens !== undefined) body.max_tokens = maxTokens; + if (parsed.options.temperature !== undefined && !modelInList(provider.noTemperatureModels, parsed.modelId)) { + body.temperature = parsed.options.temperature; + } + if (parsed.options.topP !== undefined && !modelInList(provider.noTopPModels, parsed.modelId)) { + body.top_p = parsed.options.topP; } - } else if (reasoningEffort !== undefined) { - if (provider.reasoningWireFormat === "gateway-object") { + if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences; + const reasoningDisabled = modelInList(provider.noReasoningModels, parsed.modelId); + // Some gateways accept a reasoning-effort field on a plain turn but reject the + // effort + tools combination. `noReasoningModels` would fix that only by + // stripping reasoning everywhere, costing the model its whole picker. This keeps + // the ladder advertised and drops the wire field for tool-bearing requests only. + const omitReasoningEffortWithTools = !!tools + && modelInList(provider.omitReasoningEffortWithToolsModels, parsed.modelId); + const reasoningEffort = omitReasoningEffortWithTools + ? undefined + : mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); + const nativeOpenAI = isNativeOpenAIChatTarget(provider); + let reasoningLog: AdapterRequest["reasoningLog"]; + if (!reasoningDisabled && !omitReasoningEffortWithTools && provider.reasoningWireFormat === "gateway-object" && parsed.options.reasoning === "none") { if (nativeOpenAI) { - body.reasoning_effort = reasoningEffort; + body.reasoning_effort = "none"; reasoningLog = { - effectiveEffort: reasoningEffort, + effectiveEffort: "none", wireField: "reasoning_effort", - wireValue: reasoningEffort, + wireValue: "none", }; } else { - body.reasoning = { enabled: true, effort: reasoningEffort }; - reasoningLog = { - effectiveEffort: reasoningEffort, - wireField: "reasoning.effort", - wireValue: reasoningEffort, - }; - } - } else if (modelInList(provider.thinkingBudgetModels, parsed.modelId)) { - const budget = thinkingBudgetForEffort(parsed, reasoningEffort, maxTokens); - if (budget !== undefined) { - body.thinking_budget = budget; + body.reasoning = { enabled: false }; reasoningLog = { - effectiveEffort: parsed.options.reasoning === "minimal" ? "minimal" : reasoningEffort, - wireField: "thinking_budget", - wireValue: budget, + effectiveEffort: "none", + wireField: "reasoning.enabled", + wireValue: false, }; } - } else if (modelInList(provider.thinkingToggleModels, parsed.modelId)) { - if (reasoningEffort === "enabled" || reasoningEffort === "disabled" || reasoningEffort === "adaptive") { - body.thinking = { type: reasoningEffort }; + } else if (reasoningEffort !== undefined) { + if (provider.reasoningWireFormat === "gateway-object") { + if (nativeOpenAI) { + body.reasoning_effort = reasoningEffort; + reasoningLog = { + effectiveEffort: reasoningEffort, + wireField: "reasoning_effort", + wireValue: reasoningEffort, + }; + } else { + body.reasoning = { enabled: true, effort: reasoningEffort }; + reasoningLog = { + effectiveEffort: reasoningEffort, + wireField: "reasoning.effort", + wireValue: reasoningEffort, + }; + } + } else if (modelInList(provider.thinkingBudgetModels, parsed.modelId)) { + const budget = thinkingBudgetForEffort(parsed, reasoningEffort, maxTokens); + if (budget !== undefined) { + body.thinking_budget = budget; + reasoningLog = { + effectiveEffort: parsed.options.reasoning === "minimal" ? "minimal" : reasoningEffort, + wireField: "thinking_budget", + wireValue: budget, + }; + } + } else if (modelInList(provider.thinkingToggleModels, parsed.modelId)) { + if (reasoningEffort === "enabled" || reasoningEffort === "disabled" || reasoningEffort === "adaptive") { + body.thinking = { type: reasoningEffort }; + reasoningLog = { + effectiveEffort: reasoningEffort, + wireField: "thinking.type", + wireValue: reasoningEffort, + }; + } + } else { + body.reasoning_effort = reasoningEffort; reasoningLog = { effectiveEffort: reasoningEffort, - wireField: "thinking.type", + wireField: "reasoning_effort", wireValue: reasoningEffort, }; } - } else { - body.reasoning_effort = reasoningEffort; - reasoningLog = { - effectiveEffort: reasoningEffort, - wireField: "reasoning_effort", - wireValue: reasoningEffort, - }; } - } - if (parsed.options.presencePenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) { - body.presence_penalty = parsed.options.presencePenalty; - } - if (parsed.options.frequencyPenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) { - body.frequency_penalty = parsed.options.frequencyPenalty; - } - if (provider.promptCacheKey && parsed.options.promptCacheKey !== undefined) { - body.prompt_cache_key = parsed.options.promptCacheKey; - } - // Structured-output support varies by the physical upstream model even when one - // gateway exposes a uniform OpenAI-compatible endpoint. Keep the #1137 translation - // as the default, but let an exact model opt out instead of forcing a provider-wide - // rollback that would silently return prose for siblings that support JSON Schema. - if (!provider.noStructuredOutputModels?.includes(parsed.modelId)) { - const textFormat = parsed.options.textFormat; - if (textFormat?.type === "json_object") { - body.response_format = { type: "json_object" }; - } else if (textFormat?.type === "json_schema") { - // Same downgrade as the passthrough path: the schema is dropped because the - // upstream rejects it, but the JSON-mode request itself survives. - body.response_format = provider.noJsonSchemaModels?.includes(parsed.modelId) - ? { type: "json_object" } - : { - type: "json_schema", - json_schema: { - name: textFormat.name ?? "response", - ...(textFormat.description !== undefined ? { description: textFormat.description } : {}), - ...(textFormat.schema !== undefined ? { schema: textFormat.schema } : {}), - ...(textFormat.strict !== undefined ? { strict: textFormat.strict } : {}), - }, - }; + if (parsed.options.presencePenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) { + body.presence_penalty = parsed.options.presencePenalty; + } + if (parsed.options.frequencyPenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) { + body.frequency_penalty = parsed.options.frequencyPenalty; + } + if (provider.promptCacheKey && parsed.options.promptCacheKey !== undefined) { + body.prompt_cache_key = parsed.options.promptCacheKey; + } + // Structured-output support varies by the physical upstream model even when one + // gateway exposes a uniform OpenAI-compatible endpoint. Keep the #1137 translation + // as the default, but let an exact model opt out instead of forcing a provider-wide + // rollback that would silently return prose for siblings that support JSON Schema. + if (!provider.noStructuredOutputModels?.includes(parsed.modelId)) { + const textFormat = parsed.options.textFormat; + if (textFormat?.type === "json_object") { + body.response_format = { type: "json_object" }; + } else if (textFormat?.type === "json_schema") { + // Same downgrade as the passthrough path: the schema is dropped because the + // upstream rejects it, but the JSON-mode request itself survives. + body.response_format = provider.noJsonSchemaModels?.includes(parsed.modelId) + ? { type: "json_object" } + : { + type: "json_schema", + json_schema: { + name: textFormat.name ?? "response", + ...(textFormat.description !== undefined ? { description: textFormat.description } : {}), + ...(textFormat.schema !== undefined ? { schema: textFormat.schema } : {}), + ...(textFormat.strict !== undefined ? { strict: textFormat.strict } : {}), + }, + }; + } } - } - if (tools) { - if (provider.parallelToolCalls === false) { - // NIM documents the Boolean defaulting to false and kimi rejects true; pin the - // wire bit so Codex cannot opt in via request.options. Other opted-out providers - // omit the field by default so strict OpenAI-compatible hosts never see an - // unsupported knob, but a self-hosted gateway that DOES honor the field and keeps - // emitting parallel calls without it can opt in via pinParallelToolCallsFalse. - if (provider.baseUrl === "https://integrate.api.nvidia.com/v1" - || provider.pinParallelToolCallsFalse === true) { - body.parallel_tool_calls = false; + if (tools) { + if (provider.parallelToolCalls === false) { + // NIM documents the Boolean defaulting to false and kimi rejects true; pin the + // wire bit so Codex cannot opt in via request.options. Other opted-out providers + // omit the field by default so strict OpenAI-compatible hosts never see an + // unsupported knob, but a self-hosted gateway that DOES honor the field and keeps + // emitting parallel calls without it can opt in via pinParallelToolCallsFalse. + if (provider.baseUrl === "https://integrate.api.nvidia.com/v1" + || provider.pinParallelToolCallsFalse === true) { + body.parallel_tool_calls = false; + } + } else if (provider.parallelToolCalls === true) { + body.parallel_tool_calls = parsed.options.parallelToolCalls !== false; } - } else if (provider.parallelToolCalls === true) { - body.parallel_tool_calls = parsed.options.parallelToolCalls !== false; } - } - if (parsed.stream) body.stream_options = { include_usage: true }; - - const bodyJson = JSON.stringify(body); - const actualServiceTier = typeof body.service_tier === "string" ? body.service_tier : null; - const tierLog = createAdapterTierMetadata( - parsed.options.tierObservation, - parsed.options.tierDecision, - actualServiceTier === null ? null : "service-tier", - actualServiceTier, - ); - if (isDebugEnabled()) { - let host = "upstream"; - try { host = new URL(url).host; } catch { /* keep fallback */ } - debugProviderDiagnostic("openai-chat", "request", { - host, - model: body.model, - stream: parsed.stream, - messageCount: Array.isArray(messages) ? messages.length : 0, - toolCount: Array.isArray(tools) ? tools.length : 0, - hasCredential, - bodyBytes: new TextEncoder().encode(bodyJson).length, - }); - } + if (parsed.stream) body.stream_options = { include_usage: true }; + + const bodyJson = JSON.stringify(body); + const actualServiceTier = typeof body.service_tier === "string" ? body.service_tier : null; + const tierLog = createAdapterTierMetadata( + parsed.options.tierObservation, + parsed.options.tierDecision, + actualServiceTier === null ? null : "service-tier", + actualServiceTier, + ); + if (isDebugEnabled()) { + let host = "upstream"; + try { host = new URL(url).host; } catch { /* keep fallback */ } + debugProviderDiagnostic("openai-chat", "request", { + host, + model: body.model, + stream: parsed.stream, + messageCount: Array.isArray(messages) ? messages.length : 0, + toolCount: Array.isArray(tools) ? tools.length : 0, + hasCredential, + bodyBytes: new TextEncoder().encode(bodyJson).length, + }); + } - return { - url, - method: "POST", - headers, - body: bodyJson, - ...(reasoningLog ? { reasoningLog } : {}), - ...(tierLog ? { tierLog } : {}), + return { + url, + method: "POST", + headers, + body: bodyJson, + ...(reasoningLog ? { reasoningLog } : {}), + ...(tierLog ? { tierLog } : {}), + }; }; + if (hasShrinkableOpenAIChatImages(messages) || (incoming?.imageTierBias ?? 0) > 0) { + return normalizeOpenAIChatImages(messages, { tierBias: incoming?.imageTierBias }).then(finish, finish); + } + return finish(); }, async *parseStream( diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index a4dc21adbf..3b4ce54281 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -66,3 +66,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 2d17c11875..51ca87cacb 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -128,3 +128,5 @@ changes prompt roles, not conversation identity, and cannot guarantee upstream c Instruction notice extraction scans fence ranges once and walks original lines backwards with a decreasing cursor. It accepts exactly one ASCII space inside the token notice, preserves unmatched prefix bytes, and does not repeatedly scan or copy shrinking prompt prefixes. + +Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 827540194a..1eac0e5272 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -314,3 +314,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. The integrations guide documents Cline CLI as a two-file, loopback-only integration. Hosted CI validates its source-backed fixtures; the packaged dashboard exposes it through the existing client list. + +Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. diff --git a/structure/overview.md b/structure/overview.md index d5d1a2207a..48bcd40138 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -108,3 +108,5 @@ The management quota DTO keeps Combo editing aligned with scoped inference evide see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). Cline CLI is a managed file integration: its provider settings and catalog share one recoverable journal operation. The [paired-file contract](clients/integrations.md#cline-paired-files) defines its stop/restart requirement. + +Translated Chat request construction uses the [inline-image budget](transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index e0b87add2d..38908092a1 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -264,3 +264,5 @@ fragments are not guessed onto pending ID-only calls. parallel/colliding identities, distinct unsafe raw JSON index literals, the maximum safe-integer boundary, invalid index types, missing/null continuations and UTF-8 byte-limit boundaries. + +Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index be42793e0b..af67e82e9d 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -82,3 +82,5 @@ constraints cannot widen the canonical shape. Bare shell bridge names are reject on the freeform path. Namespaced tools do not acquire bare-shell behavior. Regression coverage lives in `tests/providers/cursor/cursor-tool-definitions.test.ts`. + +Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. diff --git a/structure/runtime.md b/structure/runtime.md index 522e5cabb9..17c6aaf685 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -225,3 +225,5 @@ Cline CLI joins the existing export/client integration registries. Explicit CLI `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. + +Translated Chat request construction uses the [inline-image budget](transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index b2fc3b3fae..e94a4426a0 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -70,3 +70,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Translated Chat request construction uses the [inline-image budget](streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2321d78dd6..b831fbf7bc 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -523,3 +523,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Translated Chat request construction uses the [inline-image budget](streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 68093843ea..8f7c0cf26a 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -199,3 +199,7 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +## Translated Chat inline-image budget + +`src/adapters/openai-chat-images.ts` reuses the shared image normalization ladder for translated Chat bodies above a 3.5 MiB base64-image budget. This is best effort, not a whole-request ceiling. Remote URLs are not fetched; unprocessable and terminal images remain attached, and retained bytes continue to count during demotion. Under-budget construction stays synchronous; delegating MiMo awaits conditional asynchronous construction. Native Chat passthrough and Anthropic-only 413 retry policy retain their existing behavior. diff --git a/tests/adapters/anthropic/anthropic-image-normalize.test.ts b/tests/adapters/anthropic/anthropic-image-normalize.test.ts index 6ef58e0eac..0d650d511e 100644 --- a/tests/adapters/anthropic/anthropic-image-normalize.test.ts +++ b/tests/adapters/anthropic/anthropic-image-normalize.test.ts @@ -542,6 +542,30 @@ describe("bounded parallel first pass (WP170)", () => { expect(droppedForOverflow).toEqual([0]); }); + test("terminal overflow keeps counting a target whose drop leaves the bytes on the wire", async () => { + // A wire whose drop cannot remove bytes (openai-chat) must not have them subtracted + // from the total here either. Subtracting would let the loop believe it reached the + // budget after a drop that changed nothing, and stop before a removable target. + const encode: EncodeFn = (_input, spec) => { + const px = fakePngBase64(Math.min(64, spec.maxEdge), Math.min(64, spec.maxEdge), 3 * 1024); + return Promise.resolve({ data: px.slice(0, 4 * 1024), mediaType: "image/webp" }); + }; + const images = distinctImages(4); + const droppedForOverflow: number[] = []; + const targets: NormalizeTarget[] = images.map((b64, i) => ({ + base64: b64, + mediaType: "image/png", + replace: () => {}, + drop: note => { if (note.includes("provider request budget")) droppedForOverflow.push(i); }, + // Only the oldest target keeps its bytes when dropped. + retainsBytesOnDrop: i === 0, + })); + // Budget fits 3 of the 4 terminal outputs. Dropping the oldest frees nothing, so the + // loop must continue and drop the next one to actually get under budget. + await normalizeImageTargets(targets, { encode, budget: 3 * 4 * 1024, overflowAction: "drop" }); + expect(droppedForOverflow).toEqual([0, 1]); + }); + test("skip paths free the worker slot: URL sources and over-limit images never reach the encoder", async () => { const g = gatedEncoder(); const real = distinctImages(3); @@ -580,3 +604,30 @@ test("image codec seam preserves hook identity and owns normalization state", as expect(source).not.toMatch(/^(?:const|let|var)\b[^\n]*\bnew Map { + const images = [fakePngBase64(3000, 2000, 32), fakePngBase64(3001, 2000, 33)]; + const firstKey = Bun.hash(Uint8Array.from(Buffer.from(images[0]!, "base64"))).toString(); + let firstCalls = 0; + let secondCalls = 0; + const retained: string[] = []; + const dropped: number[] = []; + const targets: NormalizeTarget[] = images.map((base64, i) => ({ + base64, mediaType: "image/png", retainsBytesOnDrop: i === 0, + replace: data => { retained[i] = data; }, + drop: () => { dropped.push(i); }, + })); + const encode: EncodeFn = async input => { + if (Bun.hash(input).toString() === firstKey) { + if (++firstCalls > 1) throw new Error("demotion failed"); + return { data: "A".repeat(4000), mediaType: "image/jpeg" }; + } + secondCalls++; + return { data: "B".repeat(secondCalls === 1 ? 4000 : 2000), mediaType: "image/jpeg" }; + }; + await normalizeImageTargets(targets, { encode, validate: async () => {}, budget: 6000, overflowAction: "none" }); + expect(dropped).toContain(0); + expect(secondCalls).toBeGreaterThan(1); + expect(retained.map(value => value.length)).toEqual([4000, 2000]); +}); diff --git a/tests/adapters/openai/openai-chat-image-normalization.test.ts b/tests/adapters/openai/openai-chat-image-normalization.test.ts new file mode 100644 index 0000000000..5fefeaed4a --- /dev/null +++ b/tests/adapters/openai/openai-chat-image-normalization.test.ts @@ -0,0 +1,364 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import { createMimoFreeAdapter, resetMimoJwtCache } from "../../../src/adapters/mimo-free"; +import { + hasShrinkableOpenAIChatImages, + normalizeOpenAIChatImages, + OPENAI_CHAT_IMAGE_BASE64_BUDGET, +} from "../../../src/adapters/openai-chat-images"; +import { + getNormalizeStatsForTests, + resetNormalizeStateForTests, + TIER_SPECS, + type EncodeFn, +} from "../../../src/adapters/anthropic-image-normalize"; +import type { OcxMessage, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; + +// Issue #4112 follow-up: chat-completions providers such as GitHub Copilot reject a body +// over roughly 5.2MB with a bare 413 and no diagnostic content. Nothing downstream of the +// adapter can shrink a built request, so inline image bytes are normalized here. Images are +// never dropped on this wire: there is no downstream guard that would re-attach them. + +const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.githubcopilot.com", + apiKey: "sk-test", + authMode: "key", +}; + +const ONE_PX_PNG = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +/** A real, decodable PNG of the requested size, upscaled from a 1px source. */ +async function realPngB64(width: number, height: number): Promise { + const buf = await new Bun.Image(Buffer.from(ONE_PX_PNG, "base64")).resize(width, height).png().toBuffer(); + return Buffer.from(buf).toString("base64"); +} + +/** + * A flat-colour PNG compresses to almost nothing, so budget behaviour needs incompressible + * pixels. Deterministic noise is written as an uncompressed BMP and converted, which keeps + * the fixture in-repo and the encoded size realistic. + */ +async function noisyPngB64(width: number, height: number): Promise { + const rowSize = width * 3 + ((4 - ((width * 3) % 4)) % 4); + const pixelBytes = rowSize * height; + const bmp = Buffer.alloc(54 + pixelBytes); + bmp.write("BM", 0); + bmp.writeUInt32LE(bmp.length, 2); + bmp.writeUInt32LE(54, 10); + bmp.writeUInt32LE(40, 14); + bmp.writeInt32LE(width, 18); + bmp.writeInt32LE(height, 22); + bmp.writeUInt16LE(1, 26); + bmp.writeUInt16LE(24, 28); + bmp.writeUInt32LE(pixelBytes, 34); + let seed = 0x2545f491; + for (let y = 0; y < height; y++) { + let offset = 54 + y * rowSize; + for (let x = 0; x < width; x++) { + seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; seed >>>= 0; + bmp[offset++] = seed & 0xff; + bmp[offset++] = (seed >>> 8) & 0xff; + bmp[offset++] = (seed >>> 16) & 0xff; + } + } + const png = await new Bun.Image(bmp).png().toBuffer(); + return Buffer.from(png).toString("base64"); +} + + +/** Wrap raw base64 as a data URL, the only image form this wire normalizes. */ +function dataUrl(b64: string, mediaType = "image/png"): string { + return `data:${mediaType};base64,${b64}`; +} + +interface ChatPart { + type: string; + text?: string; + image_url?: { url: string }; +} + +interface ChatMsg { + role: string; + content?: string | ChatPart[]; +} + +/** Minimal parsed request carrying just the messages an adapter build needs. */ +function parsedWith(messages: OcxMessage[]): OcxParsedRequest { + return { + modelId: "claude-opus-5", + context: { messages }, + stream: false, + options: {}, + } as unknown as OcxParsedRequest; +} + +/** A user turn holding `text` plus one image per URL, in canonical (pre-wire) form. */ +function imageMessage(urls: string[], text = "what is this"): OcxMessage { + return { + role: "user", + content: [ + { type: "text", text }, + ...urls.map(url => ({ type: "image" as const, imageUrl: url })), + ], + timestamp: 0, + } as unknown as OcxMessage; +} + +/** Read the messages back out of a built request body. */ +function wireMessages(body: string): ChatMsg[] { + return (JSON.parse(body) as { messages: ChatMsg[] }).messages; +} + +/** Every image part across the given messages, flattened. */ +function imageParts(messages: ChatMsg[]): ChatPart[] { + return messages.flatMap(m => (Array.isArray(m.content) ? m.content : [])).filter(p => p.type === "image_url"); +} + +/** Deterministic encoder: output size is a function of the tier's max edge. */ +const sizedEncoder = (sizeFor: (maxEdge: number) => number): EncodeFn => + (_input, spec) => Promise.resolve({ + data: "A".repeat(sizeFor(spec.maxEdge)), + mediaType: "image/jpeg", + }); + +describe("openai-chat inline image normalization", () => { + beforeEach(() => resetNormalizeStateForTests()); + + test("a text-only turn builds synchronously and is unchanged", () => { + const request = createOpenAIChatAdapter(provider).buildRequest( + parsedWith([{ role: "user", content: "hello", timestamp: 0 } as unknown as OcxMessage]), + ); + expect(request).not.toBeInstanceOf(Promise); + const messages = wireMessages((request as { body: string }).body); + expect(messages.at(-1)?.content).toBe("hello"); + }); + + test("an image turn under the budget stays synchronous and keeps its exact bytes", async () => { + const small = await realPngB64(8, 8); + expect(hasShrinkableOpenAIChatImages([ + { role: "user", content: [{ type: "image_url", image_url: { url: dataUrl(small) } }] }, + ])).toBe(false); + + const request = createOpenAIChatAdapter(provider).buildRequest(parsedWith([imageMessage([dataUrl(small)])])); + expect(request).not.toBeInstanceOf(Promise); + const parts = imageParts(wireMessages((request as { body: string }).body)); + expect(parts).toHaveLength(1); + expect(parts[0]?.image_url?.url).toBe(dataUrl(small)); + }); + + test("an oversized turn is re-encoded through the adapter and keeps every image", async () => { + const big = await noisyPngB64(1000, 1000); + const urls = Array.from({ length: 4 }, () => dataUrl(big)); + expect(hasShrinkableOpenAIChatImages([ + { role: "user", content: urls.map(url => ({ type: "image_url", image_url: { url } })) }, + ])).toBe(true); + + const built = createOpenAIChatAdapter(provider).buildRequest(parsedWith([imageMessage(urls)])); + expect(built).toBeInstanceOf(Promise); + const { body } = await (built as Promise<{ body: string }>); + const messages = wireMessages(body); + const parts = imageParts(messages); + + expect(parts).toHaveLength(4); + const total = parts.reduce((sum, p) => sum + (p.image_url?.url.split(",")[1]?.length ?? 0), 0); + expect(total).toBeLessThanOrEqual(OPENAI_CHAT_IMAGE_BASE64_BUDGET); + for (const part of parts) expect(part.image_url?.url.startsWith("data:image/")).toBe(true); + // The caption survives alongside the images. + expect(JSON.stringify(messages)).toContain("what is this"); + }); + + test("terminal overflow keeps images attached instead of dropping the oldest", async () => { + // The input has to miss every tier's dimension and byte caps, otherwise processAt + // passes it through before the injected encoder is ever consulted and the ladder is + // never walked. A 1000x1000 noise PNG misses them; a small one does not. + const big = await noisyPngB64(1000, 1000); + const messages = [{ + role: "user", + content: Array.from({ length: 6 }, () => ({ + type: "image_url", + image_url: { url: dataUrl(big) }, + })), + }]; + const tiersReached: number[] = []; + // Every tier, including the floor, still exceeds the budget on its own. + await normalizeOpenAIChatImages(messages, { + encode: (input, spec, quality) => { + tiersReached.push(spec.maxEdge); + return sizedEncoder(() => OPENAI_CHAT_IMAGE_BASE64_BUDGET)(input, spec, quality); + }, + validate: () => Promise.resolve(), + }); + + // The ladder actually ran and bottomed out at the terminal tier. + const terminalEdge = TIER_SPECS[TIER_SPECS.length - 1]?.maxEdge; + expect(getNormalizeStatsForTests().encodeCalls).toBeGreaterThan(0); + expect(tiersReached).toContain(terminalEdge); + + const parts = imageParts(messages as ChatMsg[]); + // Still over budget at the floor, and every image survives regardless. + const total = parts.reduce((sum, p) => sum + (p.image_url?.url.split(",")[1]?.length ?? 0), 0); + expect(total).toBeGreaterThan(OPENAI_CHAT_IMAGE_BASE64_BUDGET); + expect(parts).toHaveLength(6); + for (const part of parts) expect(part.image_url?.url).toContain("base64,"); + }); + + test("an image processing failure preserves the original image without encoding", async () => { + const big = await noisyPngB64(1000, 1000); + const original = dataUrl(big); + const parsed = parsedWith([imageMessage([original])]); + const built = createOpenAIChatAdapter(provider).buildRequest(parsed, { + headers: new Headers(), + translatorBudget: createTestTranslatorBudget(), + imageTierBias: Number.NaN, + }); + const request = await (built as Promise<{ body: string }>); + const parts = imageParts(wireMessages(request.body)); + expect(parts).toHaveLength(1); + // NaN bypasses processing tiers; this exercises failed processing, not Promise rejection. + expect(parts[0]?.image_url?.url).toBe(original); + expect(getNormalizeStatsForTests().encodeCalls).toBe(0); + }); + + test("a remote https image is left untouched", async () => { + const messages = [{ + role: "user", + content: [{ type: "image_url", image_url: { url: "https://example.com/cat.png" } }], + }]; + expect(hasShrinkableOpenAIChatImages(messages)).toBe(false); + await normalizeOpenAIChatImages(messages); + expect(imageParts(messages as ChatMsg[])[0]?.image_url?.url).toBe("https://example.com/cat.png"); + }); + + test("an image this wire cannot drop keeps counting toward the budget", async () => { + // The drop callback here is a no-op, so an undecodable image stays on the wire. The + // shared core normally stops counting a dropped target, which is only correct when + // the bytes actually leave. If those bytes stopped counting, the demotion loop would + // stop early and still ship an oversized body — the exact failure this file exists + // to prevent. + const big = await noisyPngB64(1000, 1000); + // Truncated PNG: sniffs as an image, so it reaches the ladder, but cannot decode. + const corrupt = big.slice(0, 3_000_000); + const messages = [{ + role: "user", + content: [ + { type: "image_url", image_url: { url: dataUrl(corrupt) } }, + ...Array.from({ length: 3 }, () => ({ type: "image_url", image_url: { url: dataUrl(big) } })), + ], + }]; + + resetNormalizeStateForTests(); + await normalizeOpenAIChatImages(messages); + + const parts = imageParts(messages as ChatMsg[]); + const total = parts.reduce((sum, p) => sum + (p.image_url?.url.split(",")[1]?.length ?? 0), 0); + expect(parts).toHaveLength(4); + // The undecodable image is retained, unchanged. + expect(parts[0]?.image_url?.url).toBe(dataUrl(corrupt)); + // And the turn as a whole still lands under budget. + expect(total).toBeLessThanOrEqual(OPENAI_CHAT_IMAGE_BASE64_BUDGET); + }); + + test("an undecodable image keeps its original url rather than being dropped", async () => { + const corrupt = dataUrl("!!!!not-base64-image!!!!"); + const messages = [{ + role: "user", + content: [{ type: "image_url", image_url: { url: corrupt } }], + }]; + await normalizeOpenAIChatImages(messages, { + encode: () => Promise.reject(new Error("undecodable")), + validate: () => Promise.reject(new Error("undecodable")), + }); + expect(imageParts(messages as ChatMsg[])[0]?.image_url?.url).toBe(corrupt); + }); + + test("malformed message shapes neither throw nor lose parts", async () => { + const messages: unknown[] = [ + null, + "not-a-message", + { role: "user" }, + { role: "user", content: "plain text" }, + { role: "user", content: [{ type: "image_url" }, { type: "image_url", image_url: {} }] }, + ]; + const before = JSON.stringify(messages); + expect(hasShrinkableOpenAIChatImages(messages)).toBe(false); + await normalizeOpenAIChatImages(messages); + expect(JSON.stringify(messages)).toBe(before); + await normalizeOpenAIChatImages(undefined); + await normalizeOpenAIChatImages("nonsense"); + }); + + test("a delegating adapter awaits the built request instead of reading an undefined body", async () => { + // mimo-free wraps this adapter and reads baseReq.body. When an image turn makes + // buildRequest return a promise, a synchronous cast there yields undefined and the + // JSON.parse of the delegated body throws. + // mimo-free's buildRequest bootstraps a JWT over the network, so the stub below is + // what keeps this suite hermetic. Both cache resets matter: the first stops a JWT + // cached by an earlier test from bypassing the stub, the second stops this test's + // synthetic token from escaping into a later one. + const originalFetch = globalThis.fetch; + const bootstrapUrl = "https://api.xiaomimimo.com/api/free-ai/bootstrap"; + const fetched: string[] = []; + resetMimoJwtCache(); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + fetched.push(url); + if (url !== bootstrapUrl) throw new Error(`unexpected external request: ${url}`); + return Response.json({ jwt: "test-jwt" }); + }) as typeof fetch; + try { + const big = await noisyPngB64(1000, 1000); + const parsed = parsedWith([imageMessage([dataUrl(big)])]); + const adapter = createMimoFreeAdapter({ + ...provider, + adapter: "mimo-free", + baseUrl: "https://api.xiaomimimo.com/api/free-ai/openai/chat", + }); + const built = await adapter.buildRequest(parsed, { + headers: new Headers(), + translatorBudget: createTestTranslatorBudget(), + }); + expect(fetched).toEqual([bootstrapUrl]); + expect(typeof built.body).toBe("string"); + expect(imageParts(wireMessages(built.body as string))).toHaveLength(1); + } finally { + globalThis.fetch = originalFetch; + resetMimoJwtCache(); + } + }); + + test("imageTierBias from incoming meta reaches the normalizer", async () => { + const big = await noisyPngB64(1000, 1000); + const urls = Array.from({ length: 4 }, () => dataUrl(big)); + const adapter = createOpenAIChatAdapter(provider); + + const build = async (imageTierBias?: number) => { + resetNormalizeStateForTests(); + const built = adapter.buildRequest(parsedWith([imageMessage(urls)]), { + headers: new Headers(), + translatorBudget: createTestTranslatorBudget(), + ...(imageTierBias !== undefined ? { imageTierBias } : {}), + }); + const request = await (built as Promise<{ body: string }>); + return imageParts(wireMessages(request.body)) + .reduce((sum, part) => sum + (part.image_url?.url.length ?? 0), 0); + }; + + expect(await build(3)).toBeLessThan(await build()); + }); + +}); + + +test("oversized image async construction preserves current JSON schema downgrade", async () => { + resetNormalizeStateForTests(); + const big = await noisyPngB64(1000, 1000); + const parsed = parsedWith([imageMessage([dataUrl(big)])]); + parsed.options.textFormat = { type: "json_schema", name: "result", schema: { type: "object" } }; + const built = createOpenAIChatAdapter({ ...provider, noJsonSchemaModels: [parsed.modelId] }).buildRequest(parsed); + expect(built instanceof Promise).toBe(true); + const request = await built; + expect(JSON.parse(request.body as string).response_format).toEqual({ type: "json_object" }); +}); diff --git a/tests/adapters/openai/openai-chat-native-policy.test.ts b/tests/adapters/openai/openai-chat-native-policy.test.ts index 92fc1bf371..3a7530d784 100644 --- a/tests/adapters/openai/openai-chat-native-policy.test.ts +++ b/tests/adapters/openai/openai-chat-native-policy.test.ts @@ -397,4 +397,54 @@ describe("main and native Chat tier authorization parity", () => { expect(native.service_tier).toBe(main.service_tier); } }); + + test("the native lane keeps caller image bytes instead of normalizing them", async () => { + // Scope boundary for the openai-chat inline image budget (see + // tests/adapters/openai/openai-chat-image-normalization.test.ts). That budget lives in + // the adapter's buildRequest, but an eligible Chat-inbound request is dispatched down + // this native lane, which builds through buildOpenAIChatPassthroughRequest and never + // reaches the normalizer. Asserted through the real handler rather than the builder, + // so it proves the dispatcher selects that lane. Widening the budget to cover the + // fast path is a separate contract change. + const url = `data:image/png;base64,${"A".repeat(4_000_000)}`; + const captured: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + captured.push(String(init?.body ?? "")); + return Response.json({ + id: "chatcmpl_native_image", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + }); + }) as typeof fetch; + + const target = provider(); + const response = await handleChatCompletions( + new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: `${PROVIDER_NAME}/${MODEL_ID}`, + messages: [{ + role: "user", + content: [ + { type: "text", text: "what is this" }, + ...Array.from({ length: 4 }, () => ({ type: "image_url", image_url: { url } })), + ], + }], + }), + }), + { port: 0, defaultProvider: PROVIDER_NAME, providers: { [PROVIDER_NAME]: target } } as OcxConfig, + { model: "", provider: "" }, + ); + + expect(response.status).toBe(200); + expect(captured).toHaveLength(1); + const parts = (JSON.parse(captured[0]!) as { messages: Array<{ content: unknown }> }) + .messages.flatMap(m => (Array.isArray(m.content) ? m.content : [])) + .filter((p): p is { type: string; image_url: { url: string } } => + typeof p === "object" && p !== null && (p as { type?: unknown }).type === "image_url"); + // Well over the 3.5MiB image budget, and still byte-identical on the wire. + expect(parts).toHaveLength(4); + for (const part of parts) expect(part.image_url.url).toBe(url); + }); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f116d70a11..356f43eb2e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -778,6 +778,7 @@ "openai-chat-dangling-toolcalls.test.ts": "adapters/openai", "openai-chat-eof.test.ts": "adapters/openai", "openai-chat-hardening.test.ts": "adapters/openai", + "openai-chat-image-normalization.test.ts": "adapters/openai", "openai-chat-invalid-tool-call-diagnostics.test.ts": "adapters/openai", "openai-chat-model-suffix.test.ts": "adapters/openai", "openai-chat-path-override.test.ts": "adapters/openai", From b110d6374f9fd7ee7a0990640a6f0bd48c4a70e9 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:06:48 +0900 Subject: [PATCH 09/18] fix(openai-chat): retain synchronous under-budget image construction --- src/adapters/openai-chat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 6c6147c843..5cd2a63ce8 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1665,7 +1665,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd ...(tierLog ? { tierLog } : {}), }; }; - if (hasShrinkableOpenAIChatImages(messages) || (incoming?.imageTierBias ?? 0) > 0) { + if (hasShrinkableOpenAIChatImages(messages)) { return normalizeOpenAIChatImages(messages, { tierBias: incoming?.imageTierBias }).then(finish, finish); } return finish(); From eca7ce939046fe44c3985220528277cb195747fb Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 16:11:22 +0900 Subject: [PATCH 10/18] fix(cursor): preserve isolated recovery state and active cap retention --- .../content/docs/reference/proxy-formats.md | 5 +- src/adapters/cursor.ts | 1 + src/adapters/cursor/thread-continuity.ts | 8 ++- structure/providers/cursor.md | 2 +- tests/providers/cursor/cursor-adapter.test.ts | 54 +++++++++++++++++++ .../cursor-continuity-retention.test.ts | 44 ++++++++++++++- 6 files changed, 109 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 865cb63834..a1f045e981 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -29,8 +29,9 @@ Credential-bearing model, image, video, and search requests do not automatically Cursor's first bare context overflow is surfaced to the client. Later eligible requests with a stable client thread may recover with up to three conversation remints per retained scope. The in-memory allowance expires after one idle hour, eviction, or restart. Requests -without a stable thread, tool-result resumes, partial output, compaction and quota errors do -not use this recovery. This does not infer whether a task is making progress. +without a stable thread, isolated helpers, tool-result resumes, partial output, compaction +and quota errors do not use this recovery. Continued eligible overflows keep the existing +allowance active even after it is exhausted; they do not replenish it. This does not infer whether a task is making progress. ## Endpoint overview diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index a240ae1982..91e823be9a 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -468,6 +468,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda !lastRawIsToolResult && !emittedOutput && !replayUnsafe + && _parsed._cursorIsolateConversation !== true && request.contextUsageStoreCheckpoints !== false && !incoming.abortSignal?.aborted; const overflowScopeKey = cursorOverflowRemintScopeKey( diff --git a/src/adapters/cursor/thread-continuity.ts b/src/adapters/cursor/thread-continuity.ts index aa3c3dac32..fc57099f58 100644 --- a/src/adapters/cursor/thread-continuity.ts +++ b/src/adapters/cursor/thread-continuity.ts @@ -128,8 +128,14 @@ export function markCursorOverflowSurfaced(scopeKey: string): void { } export function shouldSkipCursorOverflowRemint(scopeKey: string): boolean { - pruneOverflowRemints(now()); + const at = now(); + pruneOverflowRemints(at); const entry = overflowRemintByScope.get(scopeKey); + if (entry) { + entry.updatedAt = at; + overflowRemintByScope.delete(scopeKey); + overflowRemintByScope.set(scopeKey, entry); + } return entry?.skip === true || (entry?.remintCount ?? 0) >= CURSOR_OVERFLOW_REMINT_MAX; } diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 3d734a33ed..f062ee0d13 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -85,4 +85,4 @@ Namespaced tools do not acquire bare-shell behavior. Regression coverage lives i ## Overflow remint boundary -`src/adapters/cursor.ts` surfaces the first bare context overflow before attempting conversation remint on later eligible requests. `cursorClientThreadOwner` recognizes both client thread aliases; `src/adapters/cursor/thread-continuity.ts` limits recovery to three remints per retained identity-scoped owner, with a one-hour idle TTL and 2,048-entry bound. Conversation-only requests have no stable owner and do not automatically remint. Quota/rate errors, tool-result resumes, partial output, local side effects and compaction remain fail-closed. Retention expiry, eviction or process restart resets the in-memory allowance; this is not a persistent lifetime cap or semantic-progress policy. +`src/adapters/cursor.ts` surfaces the first bare context overflow before attempting conversation remint on later eligible requests. `cursorClientThreadOwner` recognizes both client thread aliases; `src/adapters/cursor/thread-continuity.ts` limits recovery to three remints per retained identity-scoped owner, with a one-hour idle TTL and 2,048-entry bound. Conversation-only requests have no stable owner and do not automatically remint. Quota/rate errors, tool-result resumes, partial output, local side effects, isolated helper/shadow requests and compaction remain fail-closed. Isolated requests neither consume the parent allowance nor invalidate its checkpoint. Eligible overflow checks refresh existing retention timestamps and LRU position even after the cap is exhausted, without allocating absent scopes. Retention expiry, eviction or process restart resets the in-memory allowance; this is not a persistent lifetime cap or semantic-progress policy. diff --git a/tests/providers/cursor/cursor-adapter.test.ts b/tests/providers/cursor/cursor-adapter.test.ts index 1f42b697dc..9a03be37cd 100644 --- a/tests/providers/cursor/cursor-adapter.test.ts +++ b/tests/providers/cursor/cursor-adapter.test.ts @@ -1057,6 +1057,60 @@ describe("Cursor overflow conversation remint", () => { expect(seen).toHaveLength(1); }); + test("isolated non-compaction helpers preserve parent remint allowance and checkpoint", async () => { + clearCursorOverflowRemintForTests(); + clearCursorThreadContinuityForTests(); + clearCursorCheckpointsForTests(); + let attempts = 0; + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { + createTransport: () => ({ + async *run() { + attempts += 1; + throw bareOverflowError(); + }, + writeClient() {}, + }), + }); + try { + const owner = "overflow-isolated-helper"; + await adapter.runTurn?.(overflowTurnBody(owner), { headers: new Headers() }, () => {}); + expect(attempts).toBe(1); + const parentRef = commitCursorCheckpoint({ + conversationId: "cursor_parent_overflow", + identityScope: "acct-overflow-remint", + modelId: "default", + checkpointBytes: toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["overflow-isolation-fixture"], + })), + coveredMessageCount: 1, + }); + expect(parentRef).toBeDefined(); + const helper = overflowTurnBody(owner); + helper._cursorIsolateConversation = true; + helper._cursorConversationId = "cursor_parent_overflow"; + helper._providerContinuation = { + cursor: { conversationId: "cursor_parent_overflow", checkpointUsable: true, checkpointRef: parentRef }, + }; + expect(helper._compactionRequest).toBeUndefined(); + attempts = 0; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(helper, { headers: new Headers() }, event => events.push(event)); + expect(attempts).toBe(1); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", message: expect.stringContaining("Cursor context limit exceeded") }); + expect(getCursorCheckpoint(parentRef)?.ref).toBe(parentRef); + expect(lookupCursorThreadConversation(owner, "acct-overflow-remint")).toBeUndefined(); + + attempts = 0; + await adapter.runTurn?.(overflowTurnBody(owner), { headers: new Headers() }, () => {}); + expect(attempts).toBe(4); + } finally { + clearCursorOverflowRemintForTests(); + clearCursorThreadContinuityForTests(); + clearCursorCheckpointsForTests(); + } + }); + test("does not overflow-remint after non-heartbeat output was emitted", async () => { clearCursorOverflowRemintForTests(); let attempts = 0; diff --git a/tests/providers/cursor/cursor-continuity-retention.test.ts b/tests/providers/cursor/cursor-continuity-retention.test.ts index 2d3f833e8f..ea441e8ad4 100644 --- a/tests/providers/cursor/cursor-continuity-retention.test.ts +++ b/tests/providers/cursor/cursor-continuity-retention.test.ts @@ -1,14 +1,56 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { clearCursorOverflowRemintForTests, CURSOR_OVERFLOW_REMINT_MAX_ENTRIES, + CURSOR_OVERFLOW_REMINT_TTL_MS, cursorOverflowRemintCountForTests, markCursorOverflowSurfaced, + recordCursorOverflowRemint, shouldSkipCursorOverflowRemint, shouldSurfaceCursorOverflowFirst, } from "../../../src/adapters/cursor/thread-continuity"; describe("Cursor overflow remint retention", () => { + test("capped activity refreshes idle expiry without replenishing the allowance", () => { + clearCursorOverflowRemintForTests(); + let at = 1_000; + const clock = spyOn(Date, "now").mockImplementation(() => at); + try { + markCursorOverflowSurfaced("active"); + for (let attempt = 0; attempt < 3; attempt++) expect(recordCursorOverflowRemint("active")).toBe(true); + for (let interval = 0; interval < 8; interval++) { + at += CURSOR_OVERFLOW_REMINT_TTL_MS / 4; + expect(shouldSkipCursorOverflowRemint("active")).toBe(true); + expect(shouldSurfaceCursorOverflowFirst("active")).toBe(false); + } + at += CURSOR_OVERFLOW_REMINT_TTL_MS + 1; + expect(shouldSkipCursorOverflowRemint("active")).toBe(false); + expect(shouldSurfaceCursorOverflowFirst("active")).toBe(true); + expect(cursorOverflowRemintCountForTests()).toBe(0); + } finally { + clock.mockRestore(); + clearCursorOverflowRemintForTests(); + } + }); + + test("capped activity moves an existing scope behind older eviction candidates", () => { + clearCursorOverflowRemintForTests(); + try { + markCursorOverflowSurfaced("active"); + for (let attempt = 0; attempt < 3; attempt++) expect(recordCursorOverflowRemint("active")).toBe(true); + for (let index = 0; index < CURSOR_OVERFLOW_REMINT_MAX_ENTRIES - 1; index++) { + markCursorOverflowSurfaced(`other-${index}`); + } + expect(shouldSkipCursorOverflowRemint("active")).toBe(true); + markCursorOverflowSurfaced("new"); + expect(shouldSurfaceCursorOverflowFirst("other-0")).toBe(true); + expect(shouldSkipCursorOverflowRemint("active")).toBe(true); + expect(cursorOverflowRemintCountForTests()).toBe(CURSOR_OVERFLOW_REMINT_MAX_ENTRIES); + } finally { + clearCursorOverflowRemintForTests(); + } + }); + test("bounds per-scope state", () => { clearCursorOverflowRemintForTests(); for (let index = 0; index < CURSOR_OVERFLOW_REMINT_MAX_ENTRIES + 20; index++) { From 3ad908f16022d6b8464ed157af7c2cf12607448f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 16:30:26 +0900 Subject: [PATCH 11/18] docs: synchronize live sideband handshake ownership --- structure/adapters/registry.md | 2 ++ structure/catalog.md | 2 ++ structure/clients/claude-desktop.md | 2 ++ structure/data-planes/images.md | 2 ++ structure/data-planes/inbound-compat.md | 2 ++ structure/gui-and-management-api.md | 2 ++ structure/ops/service-and-sidecars.md | 2 ++ structure/providers/xai-grok.md | 2 ++ structure/subagents.md | 2 ++ structure/transports/inventory.md | 2 ++ structure/transports/responses.md | 2 ++ structure/transports/streaming-health.md | 2 ++ 12 files changed, 24 insertions(+) diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index a4dc21adbf..5d9e8322f2 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -66,3 +66,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/structure/catalog.md b/structure/catalog.md index 0ba4acca3e..9326d6f83d 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -278,3 +278,5 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Live sideband admission and its bounded upstream handshake follow the [runtime contract](runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 2914823958..115df2ed4d 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -91,3 +91,5 @@ The explicit sync coordinator also accepts Cline CLI as a separate file integrat `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](../data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. + +Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 25646c7de4..d106b16a23 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -79,3 +79,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 2d17c11875..03e77103d4 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -128,3 +128,5 @@ changes prompt roles, not conversation identity, and cannot guarantee upstream c Instruction notice extraction scans fence ranges once and walks original lines backwards with a decreasing cursor. It accepts exactly one ASCII space inside the token notice, preserves unmatched prefix bytes, and does not repeatedly scan or copy shrinking prompt prefixes. + +Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 73090d646e..bcee0def75 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -543,3 +543,5 @@ integration IO adapter. Its snapshot fingerprint cannot be checked against provi [the integration contract](clients/integrations.md#cline-paired-files) defines recovery. The existing dashboard file-client maps include Cline CLI and reuse its committed color mark. The export panel labels its download as a settings/catalog bundle; all locales explain that Undo restores both original files. + +Live sideband admission and its bounded upstream handshake follow the [runtime contract](runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 39dc9a82da..7dff0ce07b 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -142,3 +142,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 5b149ac6a2..8e998d00f2 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -65,3 +65,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/structure/subagents.md b/structure/subagents.md index f190aab084..fc793886c9 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -214,3 +214,5 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Live sideband admission and its bounded upstream handshake follow the [runtime contract](runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index b2fc3b3fae..2d2f1d36e3 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -70,3 +70,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2321d78dd6..52adc8f7e5 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -523,3 +523,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 68093843ea..d216e4a059 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -199,3 +199,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. From efb3936bdad2c172537fe3aa2e93cdda90926b9d Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 16:41:12 +0900 Subject: [PATCH 12/18] fix(search): reject malformed truncated calls before replay --- src/web-search/loop.ts | 3 +- structure/runtime.md | 2 +- tests/web-search/web-search.test.ts | 43 +++++++++++++++++++++++++++-- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 8feceb4c27..849eece2da 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -875,7 +875,8 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise event.type === "done"); - if (terminalEvent?.type === "done" && isTruncatedStopReason(terminalEvent.stopReason)) { + if (terminalEvent?.type === "done" && !split.hasMalformedToolCall + && isTruncatedStopReason(terminalEvent.stopReason)) { // A provider refusal or truncation is authoritative, even without text. // Preserve it once; neither an empty-answer retry nor a generic 502 applies. yield* replay(split.passthrough.slice(split.streamedPassthroughCount)); diff --git a/structure/runtime.md b/structure/runtime.md index 60da390f99..69c7c05a66 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -197,7 +197,7 @@ claims stored main, after terminal vision, routed vision and search exclusions. ### Empty forced search answers -`src/web-search/loop.ts` makes at most one extra answer attempt after a clean forced-answer terminal with no visible output or tool call. The recovery has no tools and reuses gathered search results. Malformed calls fail, and recognized refusal/truncation terminals pass through unchanged, including empty or partial answers. The extra generation may incur provider usage. +`src/web-search/loop.ts` makes at most one extra answer attempt after a clean forced-answer terminal with no visible output or tool call. The recovery has no tools and reuses gathered search results. Malformed calls fail before refusal/truncation passthrough, and well-formed recognized refusal/truncation terminals pass through unchanged, including empty or partial answers. The extra generation may incur provider usage. ## Scoped provider quota for Combo selection diff --git a/tests/web-search/web-search.test.ts b/tests/web-search/web-search.test.ts index 9bf7c02444..b2995a9e12 100644 --- a/tests/web-search/web-search.test.ts +++ b/tests/web-search/web-search.test.ts @@ -183,8 +183,23 @@ describe("issue #1001 — forced-answer passes must produce usable output", () = test("the recovery pass asks for text with every tool removed", async () => { const seen: OcxParsedRequest[] = []; + let sidecarCalls = 0; + const evidence = "Distinctive gathered result: fixture-42"; + globalThis.fetch = (async (input, init) => { + sidecarCalls++; + expect(String(input)).toBe("https://chatgpt.test/v1/responses"); + const body = JSON.parse(String(init?.body)); + expect(body.input[0].content[0].text).toBe("recovery fixture query"); + return new Response( + `event: response.output_text.delta\ndata: ${JSON.stringify({ type: "response.output_text.delta", delta: evidence })}\n\n` + + 'event: response.completed\ndata: {"type":"response.completed"}\n\n', + { headers: { "Content-Type": "text/event-stream" } }, + ); + }) as typeof fetch; + const actualSearch: AdapterEvent[] = webSearchFirstPass.map(event => event.type === "tool_call_delta" + ? { ...event, arguments: JSON.stringify({ query: "recovery fixture query" }) } : event); await drivePasses([ - webSearchFirstPass, + actualSearch, [{ type: "done" }], [{ type: "text_delta", text: "recovered answer" }, { type: "done" }], ], seen); @@ -194,7 +209,11 @@ describe("issue #1001 — forced-answer passes must produce usable output", () = expect(recovery.options.toolChoice).toBe("none"); expect(recovery.context.tools).toEqual([]); // The results gathered by the search reach the recovery turn as a tool result ... - expect(recovery.context.messages.filter(message => message.role === "toolResult")).toHaveLength(1); + const results = recovery.context.messages.filter(message => message.role === "toolResult"); + expect(results).toHaveLength(1); + expect(JSON.stringify(results[0])).toContain(evidence); + expect(results).toEqual(seen[1]!.context.messages.filter(message => message.role === "toolResult")); + expect(sidecarCalls).toBe(1); // ... and the recovery turn carries the developer nudge that asks for the missing text. expect(recovery.context.messages.some(message => message.role === "developer" && String(message.content).includes("no tools are available"))) @@ -210,6 +229,26 @@ describe("issue #1001 — forced-answer passes must produce usable output", () = expect(seen[2]!.options.toolChoice).toBe("none"); }); + for (const liveOutput of [false, true]) { + for (const stopReason of ["max_tokens", "content_filter"]) { + test(`malformed calls fail before ${stopReason} passthrough, live=${liveOutput}`, async () => { + const seen: OcxParsedRequest[] = []; + const frames = await drivePasses([webSearchFirstPass, [ + { type: "tool_call_start", id: "partial", name: "fixture" }, + { type: "tool_call_delta", arguments: '{"partial":' }, + { type: "tool_call_start", id: "closed", name: "fixture" }, + { type: "tool_call_delta", arguments: "{}" }, + { type: "tool_call_end" }, + { type: "done", stopReason }, + ]], seen, true, liveOutput); + expect(seen).toHaveLength(2); + expect(frames.some(frame => frame.event === "response.failed")).toBe(true); + expect(frames.some(frame => frame.event === "response.function_call_arguments.done")).toBe(false); + expect(frames.some(frame => frame.event === "response.completed" || frame.event === "response.incomplete")).toBe(false); + }); + } + } + for (const [stopReason, reason] of [["refusal", "content_filter"], ["content_filter", "content_filter"], ["max_tokens", "max_output_tokens"], ["length", "max_output_tokens"]]) { for (const partial of [false, true]) { test(`${stopReason} partial=${partial} stays authoritative without a retry`, async () => { From 4124a644385c26d699d18f175bcb04beba8b2e1d Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 17:06:21 +0900 Subject: [PATCH 13/18] fix(dashboard): retain access errors across compound poll failures --- gui/src/pages/dashboard-core-poll.ts | 63 ++++++++++++-------- gui/src/pages/use-dashboard-data.ts | 6 +- gui/tests/dashboard-connection-state.test.ts | 16 +++++ structure/gui-and-management-api.md | 2 + 4 files changed, 60 insertions(+), 27 deletions(-) diff --git a/gui/src/pages/dashboard-core-poll.ts b/gui/src/pages/dashboard-core-poll.ts index 5769d0080b..1b1e98461e 100644 --- a/gui/src/pages/dashboard-core-poll.ts +++ b/gui/src/pages/dashboard-core-poll.ts @@ -254,32 +254,45 @@ export async function fetchDashboardOverview( signal: AbortSignal, ): Promise { const failed = (failure: DashboardOverviewPoll["failure"]): DashboardOverviewPoll => ({ health: null, providers: [], error: true, failure }); - let hRes: Response; - let pRes: Response; - try { - [hRes, pRes] = await Promise.all([ - fetch(`${apiBase}/api/system/health`, { signal }), - fetch(`${apiBase}/api/providers`, { signal }), - ]); - } catch (error) { - if (isAbortError(error, signal)) throw error; - return failed("unavailable"); - } - if (hRes.status === 403 || pRes.status === 403) return failed("denied"); - if (hRes.status === 401 || pRes.status === 401) return failed("auth"); - if (!hRes.ok || !pRes.ok) return failed("request"); + const controller = new AbortController(); + const requestSignal = AbortSignal.any([signal, controller.signal]); + let accessFailure: "auth" | "denied" | undefined; + let notifyAccess!: () => void; + const accessChanged = new Promise<"access">(resolve => { notifyAccess = () => resolve("access"); }); + const observe = (response: Response) => { + if (response.status === 401 || response.status === 403) { + if (response.status === 403 || accessFailure === undefined) accessFailure = response.status === 403 ? "denied" : "auth"; + notifyAccess(); + } + return response; + }; try { - const health = await requireJson(hRes); - const providers = await requireJson(pRes); - if (!health || typeof health.status !== "string" || typeof health.version !== "string" - || !Number.isFinite(health.uptime) || health.uptime < 0 - || !Array.isArray(providers) || providers.some(row => !row || typeof row.name !== "string" - || typeof row.adapter !== "string" || typeof row.baseUrl !== "string" || typeof row.hasApiKey !== "boolean" - || (row.defaultModel !== undefined && typeof row.defaultModel !== "string"))) return failed("invalid"); - return { health, providers, error: false }; - } catch (error) { - if (isAbortError(error, signal)) throw error; - return failed("invalid"); + const requests = ["/api/system/health", "/api/providers"].map(path => + Promise.resolve().then(() => fetch(`${apiBase}${path}`, { signal: requestSignal })).then(observe)); + // Access failure must not be lost behind a rejected or stalled peer request. + const result = await Promise.race([Promise.allSettled(requests), accessChanged]); + signal.throwIfAborted(); + if (result === "access") return failed(accessFailure ?? "auth"); + const [hResult, pResult] = result; + if (hResult.status === "rejected" || pResult.status === "rejected") return failed("unavailable"); + const hRes = hResult.value; + const pRes = pResult.value; + if (!hRes.ok || !pRes.ok) return failed("request"); + try { + const health = await requireJson(hRes); + const providers = await requireJson(pRes); + if (!health || typeof health.status !== "string" || typeof health.version !== "string" + || !Number.isFinite(health.uptime) || health.uptime < 0 + || !Array.isArray(providers) || providers.some(row => !row || typeof row.name !== "string" + || typeof row.adapter !== "string" || typeof row.baseUrl !== "string" || typeof row.hasApiKey !== "boolean" + || (row.defaultModel !== undefined && typeof row.defaultModel !== "string"))) return failed("invalid"); + return { health, providers, error: false }; + } catch (error) { + if (isAbortError(error, signal)) throw error; + return failed("invalid"); + } + } finally { + controller.abort(); } } diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 410e774edc..fdff9caf08 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -1,3 +1,4 @@ +import { classifyDataSurface } from "../data-surface"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { useKeyedClientResource } from "../client-resource"; import { replaceHash } from "../hash-routing"; @@ -279,6 +280,7 @@ export function useDashboardData(apiBase: string, refreshEpoch = 0) { (signal) => fetchDashboardOverview(apiBase, signal), { pollMs: 5000 }, ); + const overviewSurface = classifyDataSurface(overviewPoll, data => data.health === null, true); const overviewReady = health !== null || overviewPoll.data !== undefined; // Preferences that are just config — never gate on overview or injection. @@ -850,8 +852,8 @@ export function useDashboardData(apiBase: string, refreshEpoch = 0) { effortCap, subagentEffortCap, effortCapSaving, setEffortCap, setSubagentEffortCap, setEffortCapSaving, syncResult, syncError, projectConfigWarnings, updateOpen, updateChannel, setUpdateRestart, updateRestart, updateLoading, - updateCheck, updateError, updateJob, reconnecting, error, - connectionFailure: overviewPoll.data?.failure, refreshDashboard: overviewPoll.refresh, + updateCheck, updateError, updateJob, reconnecting, error: error || overviewSurface.showError, + connectionFailure: overviewPoll.data?.failure ?? (overviewSurface.showError ? "unavailable" : undefined), refreshDashboard: overviewPoll.refresh, effortCapHelpTriggerRef, updateTriggerRef, maHelpTriggerRef, shadowCallHelpTriggerRef, effortCapHelpDialogRef, updateDialogRef, maHelpDialogRef, shadowCallHelpDialogRef, filteredGroups, sidecarModels, visionModels, diff --git a/gui/tests/dashboard-connection-state.test.ts b/gui/tests/dashboard-connection-state.test.ts index 7597d9bc04..9628ce1628 100644 --- a/gui/tests/dashboard-connection-state.test.ts +++ b/gui/tests/dashboard-connection-state.test.ts @@ -32,3 +32,19 @@ test("transport failure is distinct from cancelled polling", async () => { const controller = new AbortController(); controller.abort(); await expect(fetchDashboardOverview("", controller.signal)).rejects.toThrow(); }); + +for (const deniedPath of ["/api/system/health", "/api/providers"]) { + test(`a 403 from ${deniedPath} outranks a rejected peer`, async () => { + globalThis.fetch = (async input => { + if (String(input).endsWith(deniedPath)) return new Response(null, { status: 403 }); + throw new TypeError("network failed"); + }) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toMatchObject({ failure: "denied" }); + }); + test(`a 403 from ${deniedPath} does not wait for a stalled peer`, async () => { + globalThis.fetch = ((input, init) => String(input).endsWith(deniedPath) + ? Promise.resolve(new Response(null, { status: 403 })) + : new Promise((_resolve, reject) => { init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true }); })) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toMatchObject({ failure: "denied" }); + }); +} diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 21fdd69da2..84296ccfc6 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -545,3 +545,5 @@ integration IO adapter. Its snapshot fingerprint cannot be checked against provi [the integration contract](clients/integrations.md#cline-paired-files) defines recovery. The existing dashboard file-client maps include Cline CLI and reuse its committed color mark. The export panel labels its download as a settings/catalog bundle; all locales explain that Undo restores both original files. + +Dashboard overview polling observes authorization failures independently of stalled or rejected peer requests, cancels remaining child requests after a decisive result, and exposes resource-level deadline failures without rewriting them as authentication failures. From 57b3057c710a38613eaa4f81e32a17855619fc9a Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 17:31:58 +0900 Subject: [PATCH 14/18] docs: describe cancelled live sideband handshakes --- docs-site/src/content/docs/reference/proxy-formats.md | 2 +- structure/runtime.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index dedd5b83ec..ce15acf90c 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -28,7 +28,7 @@ Credential-bearing model, image, video, and search requests do not automatically The proxy completes the upstream live sideband handshake before accepting the client WebSocket. An upstream rejection fails the upgrade with 502; a ten-second handshake timeout -returns 504. Bun does not expose the exact upstream handshake status, so an upstream 404/410 +returns 504, and client cancellation returns 499. Bun does not expose the exact upstream handshake status, so an upstream 404/410 cannot currently be forwarded precisely. A successful connection preserves the initial session frames in order. This handshake policy is separate from the Responses WebSocket transport. diff --git a/structure/runtime.md b/structure/runtime.md index 3e2401ad0f..68770cb2de 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -215,7 +215,7 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- ## Live sideband handshake -`src/server/index.ts` establishes the authorized upstream live sideband before accepting the client WebSocket upgrade. `openLiveSidebandUpstream` bounds the handshake to ten seconds and retains at most 32 frames and 1 MiB of preamble within the frame limit. `src/server/ws-bridge.ts` defines the runtime handoff carrying captured frames or terminal state. Failed handshakes return 502/504; exact upstream 404/410 status is unavailable from Bun's client WebSocket. Admission ownership lasts until upstream close/CLOSED, including failed upgrades and failed attachment. The ordinary Responses WebSocket exchange remains separate. +`src/server/index.ts` establishes the authorized upstream live sideband before accepting the client WebSocket upgrade. `openLiveSidebandUpstream` bounds the handshake to ten seconds and retains at most 32 frames and 1 MiB of preamble within the frame limit. `src/server/ws-bridge.ts` defines the runtime handoff carrying captured frames or terminal state. Failed handshakes return 502/504 and client cancellation returns 499; exact upstream 404/410 status is unavailable from Bun's client WebSocket. Admission ownership lasts until upstream close/CLOSED, including failed upgrades and failed attachment. The ordinary Responses WebSocket exchange remains separate. ## Paginated history writer boundary From 52c8e8a902f09954f8baa3c42c4ad0b4474434f8 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 21:12:55 +0900 Subject: [PATCH 15/18] fix(pairing): use erasable types and explicit JSX event handlers --- devlog/_plan/260912_operations/050_pairing.md | 4 +- gui/src/connect-pairing-transport.ts | 4 +- gui/src/connect-pairing.ts | 76 ------------------- gui/src/connect-pairing.tsx | 71 +++++++++++++++++ gui/tests/connect-pairing.test.ts | 2 + .../dashboard-resource-deadline.test.tsx | 75 ++++++++++++++++++ 6 files changed, 154 insertions(+), 78 deletions(-) delete mode 100644 gui/src/connect-pairing.ts create mode 100644 gui/src/connect-pairing.tsx create mode 100644 gui/tests/dashboard-resource-deadline.test.tsx diff --git a/devlog/_plan/260912_operations/050_pairing.md b/devlog/_plan/260912_operations/050_pairing.md index 8aad40d093..009e828d58 100644 --- a/devlog/_plan/260912_operations/050_pairing.md +++ b/devlog/_plan/260912_operations/050_pairing.md @@ -4,7 +4,7 @@ Class C3; dependency roadmap. Reuse existing connected-client state and browser- MODIFY owning dashboard pending-auth component and bootstrap state: distinguish a reachable connected machine awaiting hub browser authentication from a stopped standalone proxy. Show configured hub identity/origin, explain that machine enrollment and browser session are separate, offer the current origin-specific existing pairing/authentication action. Preserve revoked/expired/unreachable states and their existing retry actions; do not suggest ocx start while the local runtime is reachable. Derive the next action from current origin + configured hub instead of a hardcoded localhost URL. No credentials appear in visible copy/URLs. -MODIFY all gui/src/i18n locale dictionaries with meaningful labels. Extend existing pending-auth/dashboard tests for local origin, remote hub origin, pending, authenticated, expired/revoked and unavailable standalone; positive browser auth transitions into connected dashboard. Exact files: gui/src/App.tsx, api.ts, pages/dashboard-core-poll.ts, pages/use-dashboard-data.ts and pages/Dashboard.tsx consume a classified authentication/error state instead of a boolean. Existing connect-pairing.ts and connect-pairing-transport.ts own hub identity and origin-specific action. Define the error classification in api.ts at response ingress; consume in polling and Dashboard; reset on authenticated success and pairing completion. No persistence/serialization for this UI state. Keep cached data with stale labeling when auth fails; do not erase a known hub into standalone offline. Public hub/browser-pairing guidance is updated with the same distinction. No service restart or live auth reconfiguration. +MODIFY all gui/src/i18n locale dictionaries with meaningful labels. Extend existing pending-auth/dashboard tests for local origin, remote hub origin, pending, authenticated, expired/revoked and unavailable standalone; positive browser auth transitions into connected dashboard. Exact files: gui/src/App.tsx, api.ts, pages/dashboard-core-poll.ts, pages/use-dashboard-data.ts and pages/Dashboard.tsx consume a classified authentication/error state instead of a boolean. Existing connect-pairing.tsx and connect-pairing-transport.ts own hub identity and origin-specific action. Define the error classification in api.ts at response ingress; consume in polling and Dashboard; reset on authenticated success and pairing completion. No persistence/serialization for this UI state. Keep cached data with stale labeling when auth fails; do not erase a known hub into standalone offline. Public hub/browser-pairing guidance is updated with the same distinction. No service restart or live auth reconfiguration. Hosted component suite and screenshot artifact of the rendered pending state required for final delivery; local GUI tests/build NOT RUN. Static source or mockup is not rendered application evidence. @@ -27,3 +27,5 @@ Pairing lifetime precision: form keyed by target server/bootstrap identity, one A amendment: post-pairing refresh explicitly reaches each dashboard keyed resource through [apiBase, refreshEpoch] dependencies; a component remount is not treated as a cache invalidation mechanism. Regression first seeds a failed overview store, completes pairing, and requires a new authenticated health/provider read plus rendered data. Reflection03/05 closure:403 keeps distinct permission-denied guidance and never starts or re-pairs a running proxy merely for denied permissions. Validate HealthData status/version strings and finite nonnegative uptime; providers must be an array of objects with the required name/adapter/baseUrl strings and hasApiKey boolean, optional defaultModel string. Invalid shapes are classified invalid even with HTTP200. Unauthorized/denied content stays hidden; only nonauth read failure may show cached data with stale notice. + +Resume C repairs: hosted34682559994 found erasableSyntaxOnly constructor parameter-property and React ref analysis at createElement form. Explicit class field and JSX component preserve behavior without disabling rules. Source compound-failure repair was already published externally at4124a644; local byte-identical patch preserved before fast-forward. Hidden-document App fixture disables periodic polls, and a controlled real resource deadline verifies retained data becomes stale. All local suites/build/typecheck/install NOT RUN. diff --git a/gui/src/connect-pairing-transport.ts b/gui/src/connect-pairing-transport.ts index cba4958538..f83dcf1d75 100644 --- a/gui/src/connect-pairing-transport.ts +++ b/gui/src/connect-pairing-transport.ts @@ -4,8 +4,10 @@ import type { ApiTarget } from "./api-targets"; const PAIRING_CODE = /^ocx_pair_[A-Za-z0-9_-]{43}$/; export class PairingError extends Error { - constructor(readonly kind: "invalid-code" | "refused" | "unreachable" | "request-failed" | "invalid-response") { + readonly kind: "invalid-code" | "refused" | "unreachable" | "request-failed" | "invalid-response"; + constructor(kind: PairingError["kind"]) { super(`pairing_${kind}`); + this.kind = kind; this.name = "PairingError"; } } diff --git a/gui/src/connect-pairing.ts b/gui/src/connect-pairing.ts deleted file mode 100644 index 5d63b51af9..0000000000 --- a/gui/src/connect-pairing.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { createElement, useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react"; -import type { ApiTarget } from "./api-targets"; -import { useT } from "./i18n/shared"; -import { PairingError, submitConnectPairing } from "./connect-pairing-transport"; -import { useCopyFeedback } from "./components/use-copy-feedback"; - -export function ConnectPairingForm({ - target, - onConnected, -}: { - target: ApiTarget; - onConnected: () => void; -}) { - const t = useT(); - const [grant, setGrant] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const activeRequest = useRef(null); - useEffect(() => () => activeRequest.current?.abort(), []); - const copyFeedback = useCopyFeedback(); - const command = `ocx gui pair --origin "${window.location.origin}"`; - const copied = copyFeedback.outcomeFor(command); - - const submit = async (event: FormEvent) => { - event.preventDefault(); - if (busy) return; - setBusy(true); - setError(null); - const controller = new AbortController(); - activeRequest.current = controller; - try { - await submitConnectPairing(target, grant, undefined, controller.signal); - if (!controller.signal.aborted) onConnected(); - } catch (failure) { - if (!controller.signal.aborted) setError(failure instanceof PairingError ? failure.kind : "unreachable"); - } finally { - if (!controller.signal.aborted) setBusy(false); - if (activeRequest.current === controller) activeRequest.current = null; - } - }; - - return createElement("section", { className: "card connect-pairing", "aria-labelledby": "connect-pairing-title" }, - createElement("h2", { id: "connect-pairing-title" }, t("connection.pairing.title")), - createElement("p", null, t("connection.pairing.hub"), ": ", createElement("code", null, target.serverOrigin)), - createElement("p", null, t("connection.pairing.getCode")), - createElement("pre", { style: { whiteSpace: "pre-wrap", overflowWrap: "anywhere" } }, createElement("code", null, command)), - createElement("button", { type: "button", className: "btn btn-ghost", onClick: () => copyFeedback.copy(command, command) }, - t(copied === "copied" ? "startup.copied" : "startup.copy")), - copied === "unavailable" ? createElement("p", { role: "status" }, t("prov.linkCopyUnavailable")) : null, - createElement("p", null, t("connection.pairing.askOperator")), - createElement("p", null, t("connection.pairing.notApiKey")), - createElement("form", { onSubmit: submit, className: "api-form-row" }, - createElement("label", { htmlFor: "connect-pairing-code", className: "field-label" }, t("connection.pairing.code")), - createElement("input", { - id: "connect-pairing-code", - name: "pairingCode", - value: grant, - onChange: (event: ChangeEvent) => setGrant(event.currentTarget.value), - autoComplete: "off", - spellCheck: false, - disabled: busy, - className: "input mono", - "aria-invalid": Boolean(error) || undefined, - "aria-describedby": error ? "connect-pairing-error" : undefined, - }), - createElement("button", { type: "submit", className: "btn btn-primary", disabled: busy || !grant.trim() }, - t(busy ? "connection.pairing.submitting" : "connection.pairing.submit")), - error ? createElement("p", { id: "connect-pairing-error", className: "alert alert-err", role: "alert" }, - t(error === "invalid-code" ? "connection.pairing.notApiKey" - : error === "unreachable" ? "connection.pairing.networkError" - : error === "request-failed" ? "connection.pairing.requestError" - : error === "invalid-response" ? "connection.pairing.responseError" : "connection.pairing.error")) : null, - ), - target.transport === "relay" ? createElement("p", { className: "text-muted" }, t("connection.pairing.relayWarning")) : null, - ); -} diff --git a/gui/src/connect-pairing.tsx b/gui/src/connect-pairing.tsx new file mode 100644 index 0000000000..c6c9750b1a --- /dev/null +++ b/gui/src/connect-pairing.tsx @@ -0,0 +1,71 @@ +import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react"; +import type { ApiTarget } from "./api-targets"; +import { useT } from "./i18n/shared"; +import { PairingError, submitConnectPairing } from "./connect-pairing-transport"; +import { useCopyFeedback } from "./components/use-copy-feedback"; + +export function ConnectPairingForm({ + target, + onConnected, +}: { + target: ApiTarget; + onConnected: () => void; +}) { + const t = useT(); + const [grant, setGrant] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const activeRequest = useRef(null); + useEffect(() => () => activeRequest.current?.abort(), []); + const copyFeedback = useCopyFeedback(); + const command = `ocx gui pair --origin "${window.location.origin}"`; + const copied = copyFeedback.outcomeFor(command); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (busy) return; + setBusy(true); + setError(null); + const controller = new AbortController(); + activeRequest.current = controller; + try { + await submitConnectPairing(target, grant, undefined, controller.signal); + if (!controller.signal.aborted) onConnected(); + } catch (failure) { + if (!controller.signal.aborted) setError(failure instanceof PairingError ? failure.kind : "unreachable"); + } finally { + if (!controller.signal.aborted) setBusy(false); + if (activeRequest.current === controller) activeRequest.current = null; + } + }; + + return
+

{t("connection.pairing.title")}

+

{t("connection.pairing.hub")}: {target.serverOrigin}

+

{t("connection.pairing.getCode")}

+
{command}
+ + {copied === "unavailable" &&

{t("prov.linkCopyUnavailable")}

} +

{t("connection.pairing.askOperator")}

+

{t("connection.pairing.notApiKey")}

+
+ + ) => setGrant(event.currentTarget.value)} + autoComplete="off" spellCheck={false} disabled={busy} className="input mono" + aria-invalid={Boolean(error) || undefined} aria-describedby={error ? "connect-pairing-error" : undefined} /> + + {error && } +
+ {target.transport === "relay" &&

{t("connection.pairing.relayWarning")}

} +
; +} diff --git a/gui/tests/connect-pairing.test.ts b/gui/tests/connect-pairing.test.ts index e65ed3c6be..05fcaa4239 100644 --- a/gui/tests/connect-pairing.test.ts +++ b/gui/tests/connect-pairing.test.ts @@ -6,6 +6,8 @@ test("App mounts the relay pairing form and installs only the returned shared se const keys = ["window", "document", "navigator", "sessionStorage", "localStorage", "fetch", "confirm", "alert", "IS_REACT_ACT_ENVIRONMENT", "__APP_VERSION__"] as const; const previous = Object.fromEntries(keys.map(key => [key, Reflect.get(globalThis, key)])); const win = new Window({ url: "http://localhost/#dashboard" }); + // Hidden documents have no periodic resource poll: pairing must explicitly revalidate. + Object.defineProperty(win.document, "visibilityState", { configurable: true, value: "hidden" }); Object.defineProperties(globalThis, { window: { configurable: true, value: win }, document: { configurable: true, value: win.document }, diff --git a/gui/tests/dashboard-resource-deadline.test.tsx b/gui/tests/dashboard-resource-deadline.test.tsx new file mode 100644 index 0000000000..be66d644c4 --- /dev/null +++ b/gui/tests/dashboard-resource-deadline.test.tsx @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { LanguageProvider } from "../src/i18n/provider"; +import { useDashboardData } from "../src/pages/use-dashboard-data"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; + +test("a controlled overview deadline marks retained dashboard data stale", async () => { + const keys = ["window", "document", "navigator", "sessionStorage", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; + const previous = new Map(keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + const win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.document, "visibilityState", { configurable: true, value: "hidden" }); + for (const [key, value] of Object.entries({ window: win, document: win.document, navigator: win.navigator, + sessionStorage: win.sessionStorage, localStorage: win.localStorage, IS_REACT_ACT_ENVIRONMENT: true })) { + Object.defineProperty(globalThis, key, { configurable: true, value }); + } + const originalTimeout = globalThis.setTimeout; + let deadline: (() => void) | undefined; + let stall = false; + let started = false; + Object.defineProperty(globalThis, "setTimeout", { configurable: true, value: (callback: (...args: unknown[]) => void, ms?: number, ...args: unknown[]) => { + if (stall && ms === 30_000 && !deadline) deadline = () => callback(...args); + return originalTimeout(callback, ms, ...args); + } }); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (path.endsWith("/api/system/health")) { + if (stall) { + started = true; + return new Promise((_resolve, reject) => init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true })); + } + return Response.json({ status: "ok", version: "fixture", uptime: 10 }); + } + if (path.endsWith("/api/providers") || path.endsWith("/api/models")) return Response.json([]); + return new Response(null, { status: 404 }); + } }); + clearClientResourceStoresForTests(); + let data: ReturnType | undefined; + function Probe() { + data = useDashboardData("/deadline"); + return {data.error ? "stale" : "fresh"}:{data.health?.version}; + } + const { createRoot } = await import("react-dom/client"); + const host = win.document.createElement("div"); + win.document.body.append(host); + const root = createRoot(host); + const waitFor = async (predicate: () => boolean) => { + const until = Date.now() + 5000; + while (!predicate()) { + if (Date.now() >= until) throw new Error("dashboard fixture did not settle"); + await act(async () => { await new Promise(resolve => setImmediate(resolve)); }); + } + }; + try { + await act(async () => { root.render(); }); + await waitFor(() => data?.health?.version === "fixture"); + expect(host.textContent).toBe("fresh:fixture"); + stall = true; + await act(async () => { data!.refreshDashboard(); }); + await waitFor(() => started && deadline !== undefined); + await act(async () => { deadline!(); }); + await waitFor(() => data?.error === true); + expect(host.textContent).toBe("stale:fixture"); + expect(data!.connectionFailure).toBe("unavailable"); + } finally { + await act(async () => { root.unmount(); }); + clearClientResourceStoresForTests(); + Object.defineProperty(globalThis, "setTimeout", { configurable: true, writable: true, value: originalTimeout }); + win.close(); + for (const key of keys) { + const descriptor = previous.get(key); + if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); + } + } +}); From 3e73ca4b2deec44914286dc9ab87122ccd8e1c43 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 21:17:49 +0900 Subject: [PATCH 16/18] docs: record operations resume and remaining verification --- devlog/_plan/260912_operations/110_resume_status.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 devlog/_plan/260912_operations/110_resume_status.md diff --git a/devlog/_plan/260912_operations/110_resume_status.md b/devlog/_plan/260912_operations/110_resume_status.md new file mode 100644 index 0000000000..2ae5182e88 --- /dev/null +++ b/devlog/_plan/260912_operations/110_resume_status.md @@ -0,0 +1,9 @@ +# Operations resume checkpoint + +Update carry #4343 merged with exact candidate f37894711158fa8215d26bed642389148ac395f6 and successful Cross-platform CI34674523305. The already-carried stop fix is not replayed. Original issue/PR closure stays with integration coordination. + +Listener #4353, usage #4357 → #4373 and pairing #4378 remain open. Published usage source/security audits passed; hosted execution must be checked on the final cumulative tip. Prior Cline registration/native-restore fixture failures are retained as failures; current dev has a separate repair, and this unit does not duplicate its ownership. + +Pairing resumed at persisted C. The previous local compound-failure patch matched the newer remote commit byte-for-byte and was preserved before fast-forward. The subsequent fix uses an erasable explicit error field and JSX event handlers for the hosted compiler/lint failures; no checks were disabled. A hidden-document pairing fixture excludes periodic polling, and a controlled resource deadline verifies stale-data marking. Hosted execution and rendered preview remain pending. + +The OpenCode management-token and local transport change remains outstanding against original #4317 CHANGES_REQUESTED. No whole-lane completion is claimed. All local suites, focused tests, GUI tests, build, typecheck and installs are NOT RUN. No service changes or release actions were performed. From 36ab8cd3eaccfeb392376555441913c5c03e2101 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 21:25:20 +0900 Subject: [PATCH 17/18] fix(opencode): separate local management catalog authority from inference Carry #4317 intent with direct local transport, pre-header loopback validation, explicit management ingress selection, redirect refusal and admin environment removal from the inference child. Preserve the catalog deadline and post-read config reload. Local suites NOT RUN; hosted regressions follow. Co-authored-by: Cortes Ventures --- .../_plan/260912_operations/060_transport.md | 2 + docs-site/src/content/docs/guides/opencode.md | 2 + .../src/content/docs/ko/guides/opencode.md | 2 + scripts/test-layout/layout.json | 1 + src/cli/opencode.ts | 50 +++++- structure/clients/claude-desktop.md | 2 + structure/config.md | 2 + structure/ops/docs-and-release.md | 2 + structure/runtime.md | 2 + tests/fixtures/test-layout-expected.json | 1 + tests/providers/opencode-cli.test.ts | 7 +- .../opencode-management-transport.test.ts | 147 ++++++++++++++++++ 12 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 tests/providers/opencode-management-transport.test.ts diff --git a/devlog/_plan/260912_operations/060_transport.md b/devlog/_plan/260912_operations/060_transport.md index a884509d22..0b8d180f4a 100644 --- a/devlog/_plan/260912_operations/060_transport.md +++ b/devlog/_plan/260912_operations/060_transport.md @@ -5,3 +5,5 @@ Class C4; dependency roadmap. Scope #4315 and the current CHANGES_REQUESTED revi The executable security design and negative-case audit live only in ignored .tmp/operations/060_transport_private.md. That file must be completed and independently reviewed before B; no pre-disclosure reasoning is copied into public planning history. Public deliverable is the implementation, regression tests and shipped contract text only. Required review dimensions: local destination selection, redirect and proxy-environment behavior, credential separation and all current callers. Original contributor credit: Cortes Ventures . No fallback that substitutes a data credential for admin authentication. Hosted regression execution plus independent security source audit bind the final patch SHA. Review state is refreshed before handoff; this work cannot approve or merge the original PR. Local suites/build/typecheck/install NOT RUN. + +P resume revalidation at c311f9bf7f5003af29fa8e7ebc2f2b5db20267f6: original4317 still CHANGES_REQUESTED, helper and sole productioncaller unchanged. Prior pairingD directs this independent slice. Reuse direct-local-http transport and local-destinations resolver; private060 contains exact diff contract and controls. No new dependencies, service changes or fallback settings. Existing8s deadline retained. New tests/providers/opencode-management-transport.test.ts isolates real socket/proxy/redirect controls and registers in both test-layout maps. Existing opencode-cli caller test changes transport spy and checks distinct management/inference credentials and generated blocks. diff --git a/docs-site/src/content/docs/guides/opencode.md b/docs-site/src/content/docs/guides/opencode.md index 2c6d7c7008..f8e44537a6 100644 --- a/docs-site/src/content/docs/guides/opencode.md +++ b/docs-site/src/content/docs/guides/opencode.md @@ -201,3 +201,5 @@ opencode must be installed and on `PATH`: ```bash npm install -g opencode-ai ``` + +The launcher reads the model catalog with the local admin token from the environment or the running proxy home. It connects directly to a loopback management listener and refuses redirects. A hub bound only to a nonlocal address needs its loopback `hub.managementIngress` enabled. The admin token is not passed into the OpenCode child; inference continues using its separate data key. If the local admin token is missing, the launcher reports the problem rather than retrying with a data key. diff --git a/docs-site/src/content/docs/ko/guides/opencode.md b/docs-site/src/content/docs/ko/guides/opencode.md index 7d5149838c..f1f5fae558 100644 --- a/docs-site/src/content/docs/ko/guides/opencode.md +++ b/docs-site/src/content/docs/ko/guides/opencode.md @@ -152,3 +152,5 @@ opencode가 설치되어 있고 `PATH`에 있어야 합니다: ```bash npm install -g opencode-ai ``` + +런처는 환경 변수 또는 실행 중인 프록시 홈의 관리자 토큰으로 모델 목록을 읽습니다. loopback 관리 리스너에 직접 연결하며 리디렉션은 거부합니다. 외부 주소에만 바인딩한 허브에서는 `hub.managementIngress`가 필요합니다. 관리자 토큰은 OpenCode 자식 프로세스에 전달하지 않습니다. 추론 요청에는 별도 데이터 키를 사용하며, 관리자 토큰이 없으면 데이터 키로 재시도하지 않고 오류를 알립니다. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4f35987b54..de0d1dc5a7 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -960,6 +960,7 @@ "openai-provider-option.test.ts": "adapters/openai", "openai-responses-passthrough.test.ts": "responses", "opencode-cli.test.ts": "providers", + "opencode-management-transport.test.ts": "providers", "opencode-free-provider.test.ts": "providers", "opencode-go-deepseek.test.ts": "providers", "opencode-go-grok46-responses.test.ts": "providers", diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index 09ea024746..2c49bd2d8d 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -41,6 +41,9 @@ import type { } from "../clients/config-export"; import { filterCatalogVisibleModels, visibleNativeSlugs } from "../codex/catalog"; import { commandInvocation } from "../lib/win-exec"; +import { configuredAdminToken } from "../lib/admin-secrets"; +import { localManagementOrigin } from "../lib/local-destinations"; +import { directLocalHttpFetch } from "../server/direct-local-http"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; import { providerCodexAccountMode } from "../providers/registry"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; @@ -306,17 +309,38 @@ function opencodeBlocks( /** Default deadline for authenticated GET /api/models during `ocx opencode` launch. */ export const OPENCODE_PROXY_MODELS_TIMEOUT_MS = 8_000; +function opencodeManagementOrigin(live: LiveProxy, override?: string): string { + if (!override && (!Number.isInteger(live.port) || live.port < 1 || live.port > 65535)) { + throw new Error("The local management port is invalid."); + } + let url: URL; + try { url = new URL(override ?? `http://${probeHostname(live.hostname)}:${live.port}`); } + catch { throw new Error("The local management address is invalid."); } + if (url.protocol !== "http:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) { + throw new Error("The catalog requires a local HTTP management origin without credentials or a path."); + } + const host = url.hostname.toLowerCase(); + if (["localhost", "localhost.", "127.0.0.1", "0.0.0.0", "[::]"].includes(host)) url.hostname = "127.0.0.1"; + else if (host !== "[::1]") { + throw new Error("The catalog requires a loopback management listener. On a hub, enable hub.managementIngress."); + } + const port = Number(url.port || 80); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("The local management port is invalid."); + return url.origin; +} + /** Fetch the live model catalog from a running proxy's management API. */ export async function fetchOpencodeProxyModels( live: LiveProxy, - apiKey: string, - deps: { fetchImpl?: typeof fetch; timeoutMs?: number } = {}, + managementToken: string, + deps: { fetchImpl?: typeof fetch; timeoutMs?: number; managementOrigin?: string } = {}, ): Promise { - const baseUrl = `http://${probeHostname(live.hostname)}:${live.port}`; - const fetchImpl = deps.fetchImpl ?? fetch; + const baseUrl = opencodeManagementOrigin(live, deps.managementOrigin); + const fetchImpl = deps.fetchImpl ?? directLocalHttpFetch; const headers = new Headers({ Accept: "application/json" }); - const token = apiKey.trim(); - if (token) headers.set("X-OpenCodex-API-Key", token); + const token = managementToken.trim(); + if (!token) throw new Error("No local admin token is available for the model catalog."); + headers.set("X-OpenCodex-API-Key", token); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), deps.timeoutMs ?? OPENCODE_PROXY_MODELS_TIMEOUT_MS); const abortIfTimedOut = (): Promise => new Promise((_, reject) => { @@ -337,10 +361,16 @@ export async function fetchOpencodeProxyModels( response = await Promise.race([ fetchImpl(`${baseUrl}/api/models`, { headers, + redirect: "error", + cache: "no-store", signal: controller.signal, }), abortIfTimedOut(), ]); + if (response.status >= 300 && response.status < 400) { + void response.body?.cancel().catch(() => {}); + throw new Error("Management catalog redirects are refused."); + } text = await Promise.race([response.text(), abortIfTimedOut()]); } catch (error) { const timedOut = error instanceof Error && error.name === "AbortError"; @@ -584,7 +614,7 @@ export function buildOpencodeEnv( const runtimeConfig = mergeOpencodeRuntimeConfig(base[OPENCODE_CONFIG_CONTENT_ENV], blocks); if (isOpencodeRuntimeConfigError(runtimeConfig)) return runtimeConfig; return { - ...base, + ...Object.fromEntries(Object.entries(base).filter(([name]) => name.toUpperCase() !== "OPENCODEX_ADMIN_AUTH_TOKEN")), [OPENCODE_CONFIG_CONTENT_ENV]: serializeOpencodeRuntimeConfig(runtimeConfig), [OPENCODE_API_KEY_ENV]: apiKey, }; @@ -652,7 +682,11 @@ export async function cmdOpencode(args: string[]): Promise { const apiKey = opencodeApiKey(startupConfig); let proxyModels: OpencodeProxyModelRow[]; try { - proxyModels = await fetchOpencodeProxyModels(live, apiKey); + const managementToken = configuredAdminToken(); + if (!managementToken) throw new Error("No local admin token is available; check the running proxy's home."); + proxyModels = await fetchOpencodeProxyModels(live, managementToken, { + managementOrigin: localManagementOrigin({ ...startupConfig, hostname: live.hostname }, live.port), + }); } catch (error) { const reason = error instanceof Error ? error.message : String(error); console.error(`❌ Could not fetch the model catalog from the proxy: ${reason}`); diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 37b7eeef4c..0f72d7a2b5 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -93,3 +93,5 @@ The explicit sync coordinator also accepts Cline CLI as a separate file integrat Config JSON preserves the boolean; only literal true activates the role-changing transform. The lightweight top-level CLI help counts Cline CLI among the fifteen registered export clients; registry parity remains covered by the client help and integration tests. + +OpenCode is a separate launcher: its management catalog read retains local admin authority in the parent, while generated provider blocks reference only the child admission environment. It does not change Desktop configuration ownership. diff --git a/structure/config.md b/structure/config.md index 4f532e789e..53fecd9030 100644 --- a/structure/config.md +++ b/structure/config.md @@ -207,3 +207,5 @@ The Cline client keeps connection settings and models in a separate native file Config JSON preserves the boolean; only literal true activates the role-changing transform. The lightweight top-level CLI help counts Cline CLI among the fifteen registered export clients; registry parity remains covered by the client help and integration tests. + +The OpenCode launcher resolves the existing local management origin from the live bind and configured hub ingress. Its admin credential comes from the existing admin environment/file policy; an absent credential fails without substituting a data key. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 6655aad7db..04c6b402c3 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -316,3 +316,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi The integrations guide documents Cline CLI as a two-file, loopback-only integration. Hosted CI validates its source-backed fixtures; the packaged dashboard exposes it through the existing client list. The lightweight top-level CLI help counts Cline CLI among the fifteen registered export clients; registry parity remains covered by the client help and integration tests. + +OpenCode launcher verification distinguishes the local management catalog request from the inference child. Its transport regressions cover proxy environment, redirects, endpoint validation, credential precedence and child-env separation on hosted CI. diff --git a/structure/runtime.md b/structure/runtime.md index 365fb04e07..4cb5c7f410 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -235,3 +235,5 @@ Config JSON preserves the boolean; only literal true activates the role-changing The lightweight top-level CLI help counts Cline CLI among the fifteen registered export clients; registry parity remains covered by the client help and integration tests. Devin CLI credential path composition in `src/oauth/devin-cli.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged. + +OpenCode catalog discovery in `src/cli/opencode.ts` uses the local admin credential and a validated numeric-loopback management origin. It dials through `src/server/direct-local-http.ts`, rejects redirects and preserves the request/body deadline. Hub ingress selection stays separate from exported inference settings. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b758e9e9d8..49897dc155 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -793,6 +793,7 @@ "openai-provider-option.test.ts": "adapters/openai", "openai-responses-passthrough.test.ts": "responses", "opencode-cli.test.ts": "providers", + "opencode-management-transport.test.ts": "providers", "opencode-free-provider.test.ts": "providers", "opencode-go-deepseek.test.ts": "providers", "opencode-go-grok46-responses.test.ts": "providers", diff --git a/tests/providers/opencode-cli.test.ts b/tests/providers/opencode-cli.test.ts index bf3552d8c0..fc0fc0625b 100644 --- a/tests/providers/opencode-cli.test.ts +++ b/tests/providers/opencode-cli.test.ts @@ -1,4 +1,5 @@ import { describe, expect, spyOn, test } from "bun:test"; +import * as directHttp from "../../src/server/direct-local-http"; import * as childProcess from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -241,7 +242,7 @@ describe("ocx opencode proxy model catalog", () => { test("the first launcher reads selection persisted during /api/models before building both provider blocks", async () => { const home = mkdtempSync(join(tmpdir(), "ocx-opencode-discovery-selection-")); - const envKeys = ["OPENCODEX_HOME", "CODEX_HOME", "XDG_CONFIG_HOME", OPENCODE_CONFIG_CONTENT_ENV]; + const envKeys = ["OPENCODEX_HOME", "CODEX_HOME", "XDG_CONFIG_HOME", "OPENCODEX_ADMIN_AUTH_TOKEN", OPENCODE_CONFIG_CONTENT_ENV]; const previous = Object.fromEntries(envKeys.map(key => [key, process.env[key]])); const configPath = join(home, "config.json"); const pending = cfg({ @@ -261,7 +262,8 @@ describe("ocx opencode proxy model catalog", () => { const finder = spyOn(liveness, "findLiveProxy").mockResolvedValue({ port: 10123, hostname: "127.0.0.1", pid: null, source: "config", }); - const fetcher = spyOn(globalThis, "fetch").mockImplementation(async input => { + const fetcher = spyOn(directHttp, "directLocalHttpFetch").mockImplementation(async (input, init) => { + expect(new Headers(init?.headers).get("x-opencodex-api-key")).toBe("fixture-admin-token"); expect(String(input)).toBe("http://127.0.0.1:10123/api/models"); expect(JSON.parse(readFileSync(configPath, "utf8")).providers.pending.initialModelSelection.status).toBe("pending"); writeFileSync(configPath, JSON.stringify(ready)); @@ -279,6 +281,7 @@ describe("ocx opencode proxy model catalog", () => { const stderr = spyOn(console, "error").mockImplementation(() => {}); try { process.env.OPENCODEX_HOME = home; + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "fixture-admin-token"; process.env.CODEX_HOME = join(home, "codex"); process.env.XDG_CONFIG_HOME = join(home, "xdg"); delete process.env[OPENCODE_CONFIG_CONTENT_ENV]; diff --git a/tests/providers/opencode-management-transport.test.ts b/tests/providers/opencode-management-transport.test.ts new file mode 100644 index 0000000000..6185991254 --- /dev/null +++ b/tests/providers/opencode-management-transport.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import * as childProcess from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as directHttp from "../../src/server/direct-local-http"; +import * as liveness from "../../src/server/proxy-liveness"; +import { buildOpencodeEnv, buildOpencodeProviderBlocksFromCatalog, cmdOpencode, fetchOpencodeProxyModels } from "../../src/cli/opencode"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; + +const admin = `ocx_admin_${"a".repeat(43)}`; +const fileAdmin = `ocx_admin_${"b".repeat(43)}`; +const dataKey = "opencode-fixture-data-key"; +const rows = [{ id: "model", provider: "fixture", namespaced: "fixture/model" }]; +const touched = ["HOME", "USERPROFILE", "OPENCODEX_HOME", "CODEX_HOME", "XDG_CONFIG_HOME", "OPENCODEX_ADMIN_AUTH_TOKEN", + "OPENCODEX_API_AUTH_TOKEN", "OCX_API_TOKEN_FILE", "OPENCODE_CONFIG_CONTENT", + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy", "NO_PROXY", "no_proxy"] as const; +let previous: Map; +let home: string; +let config: OcxConfig; + +beforeEach(() => { + previous = new Map(touched.map(key => [key, process.env[key]])); + for (const key of touched) delete process.env[key]; + home = mkdtempSync(join(tmpdir(), "ocx-opencode-transport-")); + for (const name of ["home", "codex", "xdg"]) mkdirSync(join(home, name)); + process.env.HOME = join(home, "home"); process.env.USERPROFILE = process.env.HOME; + process.env.OPENCODEX_HOME = home; process.env.CODEX_HOME = join(home, "codex"); process.env.XDG_CONFIG_HOME = join(home, "xdg"); + config = { port: 10123, hostname: "127.0.0.1", defaultProvider: "fixture", providers: { + fixture: { adapter: "openai-chat", baseUrl: "https://fixture.example.test/v1", models: ["model"], liveModels: false }, + }, apiKeys: [{ id: "one", name: "one", key: dataKey, createdAt: "2026-01-01" }] } as OcxConfig; + writeFileSync(join(home, "config.json"), JSON.stringify(config)); +}); +afterEach(() => { + for (const [key, value] of previous) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } + removeTreeWithRetry(home); +}); + +test.each(["192.0.2.1", "example.test", "[2001:db8::1]", "user@127.0.0.1", "127.0.0.1/path"])( + "rejects nonlocal or malformed catalog host %s before transport", async hostname => { + let calls = 0; + await expect(fetchOpencodeProxyModels({ hostname, port: 12345, pid: null, source: "config" }, admin, { + fetchImpl: (async () => { calls++; return Response.json(rows); }) as typeof fetch, + })).rejects.toThrow(); + expect(calls).toBe(0); + }); + +test.each([0, -1, 65536, 1.5])( "rejects invalid live port %i before transport", async port => { + let calls = 0; + await expect(fetchOpencodeProxyModels({ hostname: "127.0.0.1", port, pid: null, source: "config" }, admin, { + fetchImpl: (async () => { calls++; return Response.json(rows); }) as typeof fetch, + })).rejects.toThrow(); + expect(calls).toBe(0); +}); + +test.each(["http://user:secret@127.0.0.1:12345", "http://127.0.0.1:12345/path", "http://127.0.0.1:12345/?x=1", "https://127.0.0.1:12345"])( + "validates an explicitly selected management origin: %s", async managementOrigin => { + let calls = 0; + await expect(fetchOpencodeProxyModels({ hostname: "127.0.0.1", port: 12345, pid: null, source: "config" }, admin, { + managementOrigin, fetchImpl: (async () => { calls++; return Response.json(rows); }) as typeof fetch, + })).rejects.toThrow(); + expect(calls).toBe(0); + }); + +test.each(["127.0.0.1", "localhost", "localhost.", "0.0.0.0", "::", "::1", "[::1]"])( + "normalizes local host %s to numeric loopback", async hostname => { + const result = await fetchOpencodeProxyModels({ hostname, port: 12345, pid: null, source: "config" }, admin, { + fetchImpl: (async (input, init) => { + expect(String(input)).toBe(`http://${hostname.includes("::1") ? "[::1]" : "127.0.0.1"}:12345/api/models`); + expect(init?.redirect).toBe("error"); + return Response.json(rows); + }) as typeof fetch, + }); + expect(result).toEqual(rows); + }); + +test("production catalog transport ignores proxy environment; its control reaches the proxy", async () => { + let proxyRequests = 0; + let catalogRequests = 0; + const proxy = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => { proxyRequests++; return new Response("proxy-control"); } }); + const local = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: req => { + catalogRequests++; expect(req.headers.get("x-opencodex-api-key")).toBe(admin); return Response.json(rows); + } }); + try { + for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"]) process.env[key] = proxy.url.origin; + process.env.NO_PROXY = ""; process.env.no_proxy = ""; + expect(await fetch("http://opencode-control.invalid", { signal: AbortSignal.timeout(2000) }).then(r => r.text())).toBe("proxy-control"); + expect(proxyRequests).toBe(1); + expect(await fetchOpencodeProxyModels({ hostname: "127.0.0.1", port: local.port!, pid: null, source: "config" }, admin)).toEqual(rows); + expect(catalogRequests).toBe(1); + expect(proxyRequests).toBe(1); + } finally { await local.stop(true); await proxy.stop(true); } +}, SERVER_BUDGET_MS); + +test.each([301, 302, 307, 308])("production catalog refuses redirect %i without contacting its target", async status => { + let targetRequests = 0; + const target = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => { targetRequests++; return Response.json(rows); } }); + const redirector = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response(null, { status, headers: { location: target.url.href } }) }); + try { + await expect(fetchOpencodeProxyModels({ hostname: "127.0.0.1", port: redirector.port!, pid: null, source: "config" }, admin)).rejects.toThrow("redirects are refused"); + expect(targetRequests).toBe(0); + } finally { await redirector.stop(true); await target.stop(true); } +}, SERVER_BUDGET_MS); + +test.each(["environment", "file", "missing", "unauthorized", "redirect", "ingress", "live-host"])( + "launcher separates catalog credentials and child environment: %s", async mode => { + if (mode !== "file" && mode !== "missing") process.env.OPENCODEX_ADMIN_AUTH_TOKEN = admin; + if (mode !== "missing") writeFileSync(join(home, "admin-api-token"), fileAdmin); + if (mode === "ingress") config = { ...config, runtimeRole: "hub", hostname: "192.0.2.1", hub: { managementIngress: { enabled: true, port: 10124 } } }; + if (mode === "live-host") config.hostname = "192.0.2.2"; + writeFileSync(join(home, "config.json"), JSON.stringify(config)); + const finder = spyOn(liveness, "findLiveProxy").mockResolvedValue({ port: 10123, hostname: mode === "ingress" ? "192.0.2.1" : "127.0.0.1", pid: null, source: "config" }); + const request = spyOn(directHttp, "directLocalHttpFetch").mockImplementation(async (input, init) => { + expect(String(input)).toBe(`http://127.0.0.1:${mode === "ingress" ? 10124 : 10123}/api/models`); + expect(new Headers(init?.headers).get("x-opencodex-api-key")).toBe(mode === "file" ? fileAdmin : admin); + return mode === "unauthorized" ? new Response(null, { status: 401 }) : mode === "redirect" ? new Response(null, { status: 302 }) : Response.json(rows); + }); + let childEnv: NodeJS.ProcessEnv | undefined; + const spawn = spyOn(childProcess, "spawn").mockImplementation((...args) => { + childEnv = args[2]?.env; + const child = new childProcess.ChildProcess(); queueMicrotask(() => child.emit("exit", 0, null)); return child; + }); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + const refused = ["missing", "unauthorized", "redirect"].includes(mode); + expect(await cmdOpencode([])).toBe(refused ? 1 : 0); + expect(request).toHaveBeenCalledTimes(mode === "missing" ? 0 : 1); + expect(spawn).toHaveBeenCalledTimes(refused ? 0 : 1); + if (!refused) { + expect(childEnv?.OPENCODE_API_KEY).toBe(dataKey); + expect(childEnv?.OPENCODEX_ADMIN_AUTH_TOKEN).toBeUndefined(); + expect(childEnv?.OPENCODE_CONFIG_CONTENT).not.toContain(admin); + expect(childEnv?.OPENCODE_CONFIG_CONTENT).not.toContain(fileAdmin); + expect(childEnv?.OPENCODE_CONFIG_CONTENT).not.toContain(dataKey); + } + } finally { err.mockRestore(); spawn.mockRestore(); request.mockRestore(); finder.mockRestore(); } + }); + +test("case-insensitive inherited admin names are removed without changing other child variables", () => { + const blocks = buildOpencodeProviderBlocksFromCatalog(12345, [], undefined, config); + const env = buildOpencodeEnv(blocks, dataKey, { opencodex_admin_auth_token: admin, KEEP: "value" }); + expect(env).not.toHaveProperty("opencodex_admin_auth_token"); + expect("error" in env).toBe(false); + expect((env as Record).KEEP).toBe("value"); +}); From 535884e6964970c8c59a287053eb545e875ab212 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 21:28:08 +0900 Subject: [PATCH 18/18] docs: pin operations final verification and reconciliation scope --- devlog/_plan/260912_operations/070_verification.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/devlog/_plan/260912_operations/070_verification.md b/devlog/_plan/260912_operations/070_verification.md index e5af9101e3..03319049a6 100644 --- a/devlog/_plan/260912_operations/070_verification.md +++ b/devlog/_plan/260912_operations/070_verification.md @@ -7,3 +7,9 @@ For each independently mergeable branch: record git rev-parse HEAD, original sou A local receipt may run git diff --check and read-only hosted-result assertions; it is not a local test result. Local suites, typecheck/build/install are NOT RUN. Final behavior acceptance comes from GitHub-hosted test runs at the final SHA and independent review; author reports/old green CI are not substituted. Update ignored .tmp/operations/handoff.md as soon as each artifact exists. Include outstanding issue acceptance, original author trailers, unresolved maintainer objections, exact run links/conclusions and cycle ledger pointers. Publish template-complete PR bodies with truthful verification, screenshots for changed dashboard UI and no private investigation notes. Parent owns all integration decisions. + +P resume amendment: reconcile at pinned origin/dev db7062c37a84b12c4f59abc567d07241bf2a6042, which includes separately owned Cline/native-restore fixture repairs. No repeated rewrites. Fast-forward local lane refs to parent-published remote heads before edits; merge the pinned baseline into owned feature branches only where needed to incorporate failed-check repairs/conflicts. Never move dev/main/preview or merge PRs. Preserve shared changes and resolve only operations-owned conflicts; record any cross-lane source collision for parent. + +Listener4353 also has a documentation-only review requiring the plan to describe reuse of the already-existing managementIngressSchema. Correct020/090 wording, do not duplicate a schema. Totals4357 consumes baseline then child4373 receives that exact lower head; verify ancestry and original source patch parity. Pairing4378 incorporates baseline only once and retains all source repairs. Transport4402 is already based on repaireddev; do not rewrite its unchanged candidate for unrelated later commits. + +Final requested gate is hosted laneall on listener, cumulative usage child, pairing, and transport. Inspect live job outcomes and exacthead. New failures inside this lane become separately audited repaircycles; external owner failures are recorded without duplicate edits or baselinegreen claims. Read build artifacts from hosted GUI jobs, serve only those static files with fixture responses in isolated scratch for screenshot/interaction review, no product build/test/server locally. This is render observation, not a local suite. No liveuser service/config changes.