diff --git a/devlog/_plan/260912_operations/020_listeners.md b/devlog/_plan/260912_operations/020_listeners.md index 393ba74e8a..8ac8fbe3bf 100644 --- a/devlog/_plan/260912_operations/020_listeners.md +++ b/devlog/_plan/260912_operations/020_listeners.md @@ -11,3 +11,5 @@ MODIFY existing tests/server/ports.test.ts and tests/server/loopback-listener-in 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. + +Follow-up090 validates raw managementIngress against the existing shared strict managementIngressSchema; disappearance of the entire hub block is not evidence that the ingress itself was malformed. diff --git a/devlog/_plan/260912_operations/050_pairing.md b/devlog/_plan/260912_operations/050_pairing.md index 2b05bff2ba..dacb098a50 100644 --- a/devlog/_plan/260912_operations/050_pairing.md +++ b/devlog/_plan/260912_operations/050_pairing.md @@ -4,10 +4,28 @@ 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 on non-auth read failures; hide it when authentication or permission is denied; 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. + +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/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/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. diff --git a/devlog/_plan/260912_operations/090_listener_diagnostic.md b/devlog/_plan/260912_operations/090_listener_diagnostic.md new file mode 100644 index 0000000000..c230df5219 --- /dev/null +++ b/devlog/_plan/260912_operations/090_listener_diagnostic.md @@ -0,0 +1,7 @@ +# Precise ingress degradation diagnosis + +User intake4353 discussion3995160105. P amendment reprioritizes a source review repair before pairing; pairing source was not changed. Class C2, satisfy-spec, same tool/write bounds and no resource cap; no local suite/build/typecheck/install, no merges/services. Final behavior proof is hosted CI. + +Valid managementIngress is currently blamed when an invalid sibling makes the whole hub parse disappear. MODIFY src/config.ts: reuse the existing private managementIngressSchema in hubConfigSchema and raw-field validation; test the raw ingress with that same strict schema before issuing its field-specific warning. Do not change normalization, acceptance, port relationship validation, or whole-hub warnings. Rawvalues never printed. + +MODIFY tests/config/config-load-degrade.test.ts: valid ingress plus invalid dataPublicOrigin must warn about the actual hub sibling and not claim managementIngress invalid; preserve file bytes. Existing malformed-ingress cases still warn. Update020 and structure/config.md canonical wording. Independent source re-audit then authorized comment reply/resolution; hosted criterion remainsOPEN. 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. diff --git a/devlog/_plan/260912_operations/120_coordinator_probe.md b/devlog/_plan/260912_operations/120_coordinator_probe.md new file mode 100644 index 0000000000..db3354b6f9 --- /dev/null +++ b/devlog/_plan/260912_operations/120_coordinator_probe.md @@ -0,0 +1,15 @@ +# Coordinator-refusal fixture diagnostics + +The Windows5 failure in run34693005384 ended the transaction child at its existing15-second deadline before result assertions. The failed head and current dev have identical scoped test/connect/lock/ACL source. The log does not identify the stalled phase, so this work adds evidence and strengthens the coordinator-specific oracle; it does not claim to repair a proven production stall. + +Class C2, satisfy-spec, triggered by parent triage assignment. Scope: tests/clients/client-connect.test.ts and this plan only. No runtime, ACL policy, unrelated fixture, or timeout-budget changes. Local suites/build/typecheck/install NOT RUN; manual hosted dispatch/rerun HOLD. Existing publication and independent model-inheriting source review remain authorized. Stop this diagnostic slice when source reviewed and published; actual Windows outcome remains open until authorized hosted execution. Original operations verification criteria remain unchanged/open in verification-resume after the cancelled-CI checkpoint. + +MODIFY the existing transaction child script: synchronously emit fixed JSON phase markers on stderr around module load, connect invocation/completion, state observation and result publication. Markers contain only an allowlisted phase and bounded nonnegative integer elapsed milliseconds. They contain no home paths, URL, credentials, error text or raw output. + +MODIFY the parent fixture: parse only those marker objects on failure before throwing ClientStateProbeError, and include only validated phase/elapsed evidence. Preserve15-second kill, successful exit requirement, output parse and all cleanup. A valid result followed by a retained child still fails. Unknown/malformed marker fields are ignored; never expose raw stderr/stdout. Existing other fixtures keep the same constructor behavior. + +MODIFY the coordinator oracle: inspect the caught cause chain for the exact CONFIG_MUTATION_LOCK_UNAVAILABLE code and expose only a boolean in fixture JSON. Assert this in addition to connected=null, calls=[], absent token/catalog and zeroed authority. An earlier lifecycle refusal must not pass as coordinator coverage. No matching on arbitrary raw error prose. + +Extend existing fixture self-checks with valid phase evidence on a retained child and malformed/unknown/secret-shaped marker controls. All original expectations remain. Independent A/C review checks marker privacy, actual reachability, timeout/cleanup retention and unchanged production source. git diff --check is whitespace evidence only. Hosted execution after HOLD must distinguish module loading, connect-internal delay and completed-result exit retention; until then no root-cause or baseline-green claim. + +Design reflection accepted: cause traversal uses a seen-object set and finite depth; connect markers distinguish entered, resolved and rejected outcomes; result_published follows serialization/output. Parent scans a bounded stderr tail and accepts only fixed phase names plus finite nonnegative integer elapsed milliseconds within a fixed cap. A valid marker followed by malformed input retains the valid evidence. These markers do not identify a particular inner ACL/lock call. 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/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index c50ef934d2..24bc7c5f3d 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/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 0db5e7bcd5..445d5355ef 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -722,3 +722,11 @@ 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. + +If an auxiliary listener cannot bind, startup names `unauthenticatedLoopbackListener` or `hub.managementIngress` and the actual address. Correct that listener or free its address; changing only the public proxy port does not repair a fixed auxiliary port. Malformed hand-edited listener blocks warn and remain disabled while unrelated settings are preserved. 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/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/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/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 7cec1af766..d016a75c67 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -24,6 +24,30 @@ 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. + +## 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, 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. + +## 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, 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. + ## Endpoint overview | Client surface | Endpoint | Successful non-stream result | Successful stream or socket result | 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..f83dcf1d75 100644 --- a/gui/src/connect-pairing-transport.ts +++ b/gui/src/connect-pairing-transport.ts @@ -3,6 +3,15 @@ import type { ApiTarget } from "./api-targets"; const PAIRING_CODE = /^ocx_pair_[A-Za-z0-9_-]{43}$/; +export class PairingError extends Error { + readonly kind: "invalid-code" | "refused" | "unreachable" | "request-failed" | "invalid-response"; + constructor(kind: PairingError["kind"]) { + super(`pairing_${kind}`); + this.kind = kind; + this.name = "PairingError"; + } +} + /** * Exchange a pairing code for a shared-plane session. * @@ -15,9 +24,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 +37,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 deleted file mode 100644 index 00e48abd7a..0000000000 --- a/gui/src/connect-pairing.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { createElement, useState, type ChangeEvent, type FormEvent } from "react"; -import type { ApiTarget } from "./api-targets"; -import { useT } from "./i18n/shared"; -import { submitConnectPairing } from "./connect-pairing-transport"; - -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(false); - - const submit = async (event: FormEvent) => { - event.preventDefault(); - if (busy) return; - setBusy(true); - setError(false); - try { - await submitConnectPairing(target, grant); - onConnected(); - } catch { - setError(true); - } finally { - setBusy(false); - } - }; - - 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("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": 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, - ), - ); -} 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/src/i18n/de.ts b/gui/src/i18n/de.ts index db6b8c889f..51dc5d0b7c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2574,6 +2574,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 74e4ded4d1..c9144ca6e6 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2649,6 +2649,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 2bc3d5ab51..59b4802348 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2562,6 +2562,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 a787ab735b..6f2be98287 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2595,6 +2595,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 ccd2b06735..af866c00ea 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2596,6 +2596,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 ceb0c18a92..ed1c97f938 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2597,6 +2597,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 26a8f93d09..cf9797a91f 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2597,6 +2597,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 1fb8387f4d..6b031d669f 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2560,6 +2560,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 4e2cd831cc..2735475749 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2595,6 +2595,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..1b1e98461e 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,46 @@ export async function fetchDashboardOverview( apiBase: string, signal: AbortSignal, ): Promise { + const failed = (failure: DashboardOverviewPoll["failure"]): DashboardOverviewPoll => ({ health: null, providers: [], error: true, failure }); + 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 [hRes, pRes] = await Promise.all([ - fetch(`${apiBase}/api/system/health`, { signal }), - fetch(`${apiBase}/api/providers`, { signal }), - ]); - const health = await requireJson(hRes); - const providers = await requireJson(pRes); - return { health, providers, error: false }; - } catch { - return { health: null, providers: [], error: true }; + 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 5bbe1210bc..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"; @@ -135,7 +136,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 +254,7 @@ export function useDashboardData(apiBase: string) { const startupHealthPoll = useKeyedClientResource( `dashboard-startup-health:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], (signal) => fetchStartupHealth(apiBase, signal), { pollMs: 30_000 }, ); @@ -275,23 +276,24 @@ 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 }, ); + 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. 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 +304,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 +316,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 +332,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 }, ); @@ -850,7 +852,8 @@ export function useDashboardData(apiBase: string) { effortCap, subagentEffortCap, effortCapSaving, setEffortCap, setSubagentEffortCap, setEffortCapSaving, syncResult, syncError, projectConfigWarnings, updateOpen, updateChannel, setUpdateRestart, updateRestart, updateLoading, - updateCheck, updateError, updateJob, reconnecting, error, + 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/connect-pairing.test.ts b/gui/tests/connect-pairing.test.ts index ae68a1d1f7..05fcaa4239 100644 --- a/gui/tests/connect-pairing.test.ts +++ b/gui/tests/connect-pairing.test.ts @@ -5,7 +5,9 @@ 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" }); + // 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 }, @@ -33,6 +35,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 +56,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 +102,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 +116,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 +134,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 +215,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..9628ce1628 --- /dev/null +++ b/gui/tests/dashboard-connection-state.test.ts @@ -0,0 +1,50 @@ +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(); +}); + +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/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); + } + } +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4f4ec44705..55eb841245 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -363,6 +363,7 @@ "cli-registry.test.ts": "cli", "cli-restart-health.test.ts": "cli", "cli-restore-back.test.ts": "cli", + "cli-start-auxiliary-bind.test.ts": "cli", "cli-start-journal-order.test.ts": "cli", "cli-status-hub-state.test.ts": "cli", "cli-status-json.test.ts": "cli", @@ -550,6 +551,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", @@ -952,6 +954,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", @@ -967,6 +970,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/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 0affb33b55..e8a4af7f6f 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/cursor.ts b/src/adapters/cursor.ts index 7382c25d8c..91e823be9a 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,108 @@ 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 + && _parsed._cursorIsolateConversation !== true + && 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..fc57099f58 100644 --- a/src/adapters/cursor/thread-continuity.ts +++ b/src/adapters/cursor/thread-continuity.ts @@ -65,3 +65,96 @@ 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 { + 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; +} + +/** 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/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..5cd2a63ce8 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)) { + return normalizeOpenAIChatImages(messages, { tierBias: incoming?.imageTierBias }).then(finish, finish); + } + return finish(); }, async *parseStream( diff --git a/src/cli/index.ts b/src/cli/index.ts index 97a80f32c8..93c963833b 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -63,7 +63,7 @@ import { import { requestBoundSystemRestart } from "./system-restart-client"; import { installCrashGuards } from "../lib/crash-guard"; import { dispatchCommand , decideStartWithLiveOwner } from "./dispatch"; -import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports"; +import { AuxiliaryListenerBindError, findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import { createReadinessGate } from "../server/readiness"; import { isApiAuthRequired } from "../server/auth-cors"; @@ -391,7 +391,7 @@ async function handleStart(options: { block?: boolean } = {}) { scheduleCatalogPrewarm(); break; } catch (err) { - if (!isAddrInUse(err) || attempt >= 2) throw err; + if (err instanceof AuxiliaryListenerBindError || !isAddrInUse(err) || attempt >= 2) throw err; if (requestedPort !== undefined) { console.log(`⚠️ Port ${port} was taken while starting; waiting to retry the same port...`); const hostname = loadConfig().hostname ?? "127.0.0.1"; 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/src/config.ts b/src/config.ts index 5e81a7e5f1..bd474a4bd4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1041,6 +1041,11 @@ function canonicalHttpOrigin(value: string): string | null { } } +const managementIngressSchema = z.union([ + z.object({ enabled: z.literal(false) }).strict(), + z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }).strict(), +]); + const hubConfigSchema = z.object({ managementPublicOrigin: z.string().transform((value, ctx) => { const origin = canonicalHttpOrigin(value); @@ -1064,10 +1069,7 @@ const hubConfigSchema = z.object({ }).optional(), // A malformed hand edit disables only the optional ingress. Live writes are rejected by // managementIngressConfigError before this load-time degradation can hide the mistake. - managementIngress: z.union([ - z.object({ enabled: z.literal(false) }).strict(), - z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }).strict(), - ]).optional().catch(undefined), + managementIngress: managementIngressSchema.optional().catch(undefined), }).strict(); const tailscaleUserSchema = z.string().trim().min(1).superRefine((value, ctx) => { @@ -1959,6 +1961,26 @@ function warnDegradedHostname(rawParsed: unknown, validated: OcxConfig): void { } } +function degradedListenerWarnings(rawParsed: unknown, validated: OcxConfig): string[] { + const raw = rawConfigRecord(rawParsed); + if (!raw) return []; + const warnings: string[] = []; + if (raw.unauthenticatedLoopbackListener !== undefined && validated.unauthenticatedLoopbackListener === undefined) { + warnings.push("unauthenticatedLoopbackListener ignored: invalid listener configuration; repair config.json before enabling the listener"); + } + const hub = rawConfigRecord(raw.hub); + if (hub?.managementIngress !== undefined && !managementIngressSchema.safeParse(hub.managementIngress).success) { + warnings.push("hub.managementIngress ignored: invalid management listener configuration; repair config.json before enabling the listener"); + } + return warnings; +} + +function warnDegradedListeners(rawParsed: unknown, validated: OcxConfig): void { + for (const warning of degradedListenerWarnings(rawParsed, validated)) { + console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); + } +} + /** * Companion to {@link warnDegradedStreamMode} for a malformed selection-order map. * Priority is a preference, so the schema drops the whole map rather than failing @@ -2409,6 +2431,7 @@ export function loadConfig(): OcxConfig { warnInheritedFastWireConflicts(configPath, config); warnDegradedStreamMode(parsed, config); warnDegradedHostname(parsed, config); + warnDegradedListeners(parsed, config); warnDegradedApiKeys(parsed, config); warnDegradedCodexAccountPriorities(parsed, config); warnDegradedCodexQuotaAutoRefresh(parsed, config); @@ -2438,6 +2461,7 @@ export function loadConfig(): OcxConfig { const config = normalizeApiKeyIds(retryResult.data as OcxConfig); warnInheritedFastWireConflicts(configPath, config); warnDegradedHostname(parsed, config); + warnDegradedListeners(parsed, config); warnDegradedApiKeys(parsed, config); warnDegradedCodexAccountPriorities(parsed, config); warnDegradedCodexQuotaAutoRefresh(parsed, config); @@ -2463,6 +2487,7 @@ export function loadConfig(): OcxConfig { const config = normalizeApiKeyIds(salvaged.parsed); warnInheritedFastWireConflicts(configPath, config); warnDegradedHostname(parsed, config); + warnDegradedListeners(parsed, config); warnDegradedApiKeys(parsed, config); warnDegradedCodexAccountPriorities(parsed, config); warnDegradedCodexQuotaAutoRefresh(parsed, config); @@ -2597,6 +2622,7 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf const warnings = configPlaceholderWarnings(normalized); warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning)); warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized)); + warnings.push(...degradedListenerWarnings(rawParsed, normalized)); const quotaAutoRefreshWarning = degradedCodexQuotaAutoRefreshWarning(rawParsed, normalized); if (quotaAutoRefreshWarning) warnings.push(quotaAutoRefreshWarning); if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) { @@ -3050,10 +3076,13 @@ function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics { // that ignores the error and writes it back preserves what the operator configured. const salvaged = salvageConfigCandidate(merged, retryResult.error); if (salvaged) { + const config = normalizeApiKeyIds(salvaged.parsed); + const warnings = degradedListenerWarnings(parsed, config); return { - config: normalizeApiKeyIds(salvaged.parsed), + config, source: "fallback", error: schemaDiagnosticsError(result.error), + ...(warnings.length > 0 ? { warnings } : {}), }; } diff --git a/src/server/index.ts b/src/server/index.ts index 3e458a3fea..0f9cd144dd 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,3 +1,4 @@ +import { AuxiliaryListenerBindError } from "./ports"; import { markActivity } from "../lib/sidecar-tracker"; import { knownModelIdsForProvider } from "../router"; import { @@ -8,6 +9,8 @@ import { buildResponsesWsData, sendResponseToWebSocket, sendTextFrame, + type LiveSidebandUpstreamFailure, + type LiveSidebandUpstreamHandoff, type WsData, } from "./ws-bridge"; import type { Server, ServerWebSocket } from "bun"; @@ -321,6 +324,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; @@ -418,6 +444,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; @@ -450,29 +518,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; @@ -505,20 +787,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 @@ -2216,19 +2484,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); } @@ -2556,7 +2869,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { const server = createServer(); 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/src/web-search/loop.ts b/src/web-search/loop.ts index 99b275ed8d..849eece2da 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,34 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise event.type === "done"); + 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)); + 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/adapters/registry.md b/structure/adapters/registry.md index aa0bc914cb..d8020bdc71 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -61,6 +61,8 @@ so the schema is not something a user can fix from configuration (issue #2673). > Decision record: [ADR-0093](../decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md) +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). + ## Truncated tool finalization The bridge keeps an open function, custom, or tool-search call incomplete when an adapter ends with a recognized truncated stop reason. Streaming emits no argument/input completion frame for that open call, and buffered JSON applies the same status. A call already closed by its own tool-call end retains its completed state. The response remains incomplete, partial output is preserved, and truncated compaction never replaces history. @@ -75,3 +77,9 @@ 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. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. + + +Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. + + +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/catalog.md b/structure/catalog.md index 49f0e353e1..7e343d4e5a 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -267,6 +267,8 @@ spelling; the V1 and compaction cap exemptions are preserved. Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). +Listener startup diagnostics follow [the runtime lifecycle contract](runtime.md#lifecycle); malformed optional listener blocks follow [config loading](config.md#config-surface). + 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. @@ -280,3 +282,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 54689b36a1..242d545e57 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -76,6 +76,8 @@ testable on any host: stubbing `process.platform` does not propagate to `os.plat > Decision record: [ADR-0046](../decisions/ADR-0046-claude-desktop-config-library-resolution.md) +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). + 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. @@ -95,3 +97,7 @@ 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. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. + +Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. + +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 b37d315fce..d863826750 100644 --- a/structure/config.md +++ b/structure/config.md @@ -55,6 +55,8 @@ matters for maintainers is which groups exist and who resolves them: Env values are resolved through `src/config.ts`, so a config value naming an env var never persists the secret itself. +Malformed optional data-loopback and nested hub-management listener blocks are disabled in memory and reported by load-time warnings and read-only config diagnostics. Ingress warnings validate the raw ingress independently, so an invalid hub sibling does not falsely blame a valid ingress. The warning names only the field; unrelated providers and keys survive. Explicit writes remain strictly validated. + ## Config injection `src/codex/inject.ts` writes one of two forms. The choice is not cosmetic: it decides whether Codex @@ -214,3 +216,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/data-planes/images.md b/structure/data-planes/images.md index d1d0193048..cfc0cd3aaa 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -70,6 +70,8 @@ conflicts with `modelSupportsReasoningSummaries: false` for the same model. > Decision record: [ADR-0045](../decisions/ADR-0045-standalone-images.md) +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). + 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. @@ -80,3 +82,6 @@ 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. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. + + +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 3e940b27a4..244a038c31 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -111,6 +111,8 @@ reasoning ladder into `thinking.effortOptions`. Missing capabilities stay absent falling back to OpenCodex guesses, and the integration does not write the removed `thinking.effort` / `defaultEffort` fields because MCode owns the active effort per session. +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). + 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. @@ -150,3 +152,9 @@ Instruction notice extraction scans fence ranges once and walks original lines b 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. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. + + +Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. + + +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/design-methodology.md b/structure/design-methodology.md index 690b3b1287..9af35d91fc 100644 --- a/structure/design-methodology.md +++ b/structure/design-methodology.md @@ -40,4 +40,6 @@ 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. + Cline uses the existing file-integration page, tabs, status badge and rollback dialogs. Its localized semantics identify both files and the required stop/restart boundary before users mutate them. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 9fde77fc9e..f6d8691463 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -512,6 +512,8 @@ picker data resource so failure cannot erase the ordinary model inventory; Apply the resource's generation fence, and Most used reads usage only on explicit Apply. Stored mode survives availability drift, while complete/native custom orders await explicit replacement. +Listener startup diagnostics follow [the runtime lifecycle contract](runtime.md#lifecycle); malformed optional listener blocks follow [config loading](config.md#config-surface). + 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. @@ -538,9 +540,17 @@ 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. + Cline journal Undo eligibility reads both native configuration files through the paired integration IO adapter. Its snapshot fingerprint cannot be checked against providers.json alone; [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. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. + + +Live sideband admission and its bounded upstream handshake follow the [runtime contract](runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. + + +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. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 9570086e21..5ba00cca52 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -304,6 +304,8 @@ The Remote Hub guide and affected CLI, server-config, management-API, and dashbo Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](../providers/openai-tiers.md#quota-cache-and-short-window-history). +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). + The Combo guides describe the distinction between display quota and single-credential inference evidence used by routing. See [scoped provider quota](../runtime.md#scoped-provider-quota-for-combo-selection). The management quota DTO keeps Combo editing aligned with scoped inference evidence; @@ -313,8 +315,14 @@ 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 and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. 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. + 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. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. + +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. + +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/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index c8ef069110..7a35ae499c 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -133,6 +133,8 @@ so the flag does not identify the peer responsible for corruption. Existing diag not rewritten. Audio devices, WebRTC media negotiation, captions and spoken handoff delivery remain client responsibilities. +Auxiliary listener startup failures report their own effective address and do not trigger public-port retries; the synchronous rollback contract is described in [Runtime](../runtime.md#lifecycle). + 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. @@ -143,3 +145,6 @@ 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. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. + + +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/overview.md b/structure/overview.md index d5d1a2207a..45fd8e708d 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -104,7 +104,13 @@ would pass while the rule was violated. - **INV-HOME-01** — `CODEX_HOME` wins over `~/.codex` when present and valid. - **INV-SLUG-01** — Routed model slugs use `provider/model`. +Listener startup diagnostics follow [the runtime lifecycle contract](runtime.md#lifecycle); malformed optional listener blocks follow [config loading](config.md#config-surface). + 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. + 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 7cf84ef7ec..086db64202 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -269,3 +269,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..bebe5dad85 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -82,3 +82,9 @@ 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, 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. + +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/openai-tiers.md b/structure/providers/openai-tiers.md index 50c7d28ce3..56953c5cab 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -417,6 +417,8 @@ model settings, and noncanonical `openai` rows never receive that recovery path. terminal 403 codes as `needsReauth`; generic permission failures remain non-terminal, and a successful main usage refresh clears the runtime mark. +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). + ## 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 and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index fc6cf7c63f..acab734ece 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -54,6 +54,8 @@ malformed, gapped, oversized, contradictory, failed, or incomplete streams stay - **Safety & Idempotency:** Managed via `src/grok/reset-coupon-ledger.ts` using UUIDv4 operation tracking before upstream dispatch to prevent duplicate consumption during network flakes. - **Surfaces:** `ocx account grok-reset-coupons` in the terminal, and the dashboard at Providers > xAI Grok > Accounts, where each OAuth row carries a ticket badge with its remaining count and opens a redemption dialog (`gui/src/hooks/useGrokResetCoupons.ts`, `gui/src/components/provider-workspace/GrokResetCoupons.tsx`). The dashboard reads one `GET /api/grok/reset-coupons` per account with at most three in flight, always sends an explicit `tokenId` and a client-minted `operationId`, and treats redemption truth as the settled `code` rather than HTTP 200 — a replayed *failure* returns 200 with `replayed: true`. After a request times out it issues no further consume call, because a redemption whose ledger record is still `open` re-executes. +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). + Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. @@ -89,3 +91,5 @@ The upstream tier echo relays to the client on every Chat Completions delivery s (`src/chat/outbound.ts` projections and `src/server/chat-native-sse.ts` chunks), matching what the Responses lane already relayed for responses-wire upstreams; the responses-lane assembly for chat-wire upstreams tracks the echo in attempt telemetry only. + +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/runtime.md b/structure/runtime.md index 7a0231f097..55504a1ca1 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -71,6 +71,8 @@ The hub-management socket is enabled only by `runtimeRole: "hub"` plus `hub.managementIngress.enabled`, always binds `127.0.0.1`, and default-denies everything except GUI, session bootstrap/exchange, and `/api/*`. +Auxiliary listener bind failures carry the listener key and effective address through `AuxiliaryListenerBindError` in `src/server/ports.ts`. `src/cli/index.ts` reports them without retrying the public port. Startup still rolls back every earlier socket synchronously. + A failed optional bind initiates rollback of every earlier socket; normal stop joins all bound sockets before lifecycle release. The existing launchd/systemd installer remains the service owner and continues loading the data token from `service-api-token`; hub mode adds no service-manager @@ -202,6 +204,10 @@ 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 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 `src/providers/quota.ts` publishes routing evidence only when a producer explicitly supplies its @@ -220,6 +226,10 @@ 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 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 `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 and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. @@ -238,3 +248,7 @@ The lightweight top-level CLI help counts Cline CLI among the fifteen registered 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. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. + +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. + +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/structure/subagents.md b/structure/subagents.md index 24d37af883..443c7d3552 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -199,6 +199,8 @@ Native Codex advertisements still follow display priority; private guidance rank Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). +Listener startup diagnostics follow [the runtime lifecycle contract](runtime.md#lifecycle); malformed optional listener blocks follow [config loading](config.md#config-surface). + 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. @@ -215,3 +217,6 @@ 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. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. + + +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 623abe10a8..10ee10647f 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -60,6 +60,8 @@ Caller-owned `provider.fetch` executors are also deferred: they receive literal/ redirect blocking, but cannot inherit DNS classification or peer pinning without a verified-peer executor contract. Main-request migration must not treat that branch as fixed-transport equivalent. +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). + 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. @@ -75,3 +77,7 @@ privately to final dispatch; preliminary route selection does not inject Go-only 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. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. + +Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. + +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 9b2c0f883e..40af2f75e4 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -536,6 +536,8 @@ not retried. > Decision record: [ADR-0071](../decisions/ADR-0071-combo-streaming-commit-boundary.md) +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). + 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. @@ -546,3 +548,9 @@ 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. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. + + +Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. + + +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 6a5574c6d2..57b09c8fe7 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -190,6 +190,8 @@ WebSocket clients observe the same canonical lifecycle. frame rather than always emitting `response.completed`. If the response status is `failed`, a `response.failed` frame is sent; otherwise `response.completed` carries through the original status. +Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). + 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. @@ -200,3 +202,11 @@ 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. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. + + +Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. + + +## 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/cli/cli-start-auxiliary-bind.test.ts b/tests/cli/cli-start-auxiliary-bind.test.ts new file mode 100644 index 0000000000..7f37113014 --- /dev/null +++ b/tests/cli/cli-start-auxiliary-bind.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { findAvailablePort } from "../../src/server/ports"; +import { repoPath } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { watchdogMs } from "../helpers/ci-watchdog"; + +const DEADLINE = watchdogMs(20_000); + +for (const listener of ["unauthenticatedLoopbackListener", "hub.managementIngress"] as const) { + for (const pinned of [false, true]) { + test(`${listener} failure never retries the public port (${pinned ? "pinned" : "soft"})`, async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-auxiliary-bind-")); + const home = join(root, "home"); + const ocxHome = join(root, "ocx"); + const codexHome = join(root, "codex"); + for (const path of [home, ocxHome, codexHome]) mkdirSync(path); + const occupied = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("occupied") }); + const auxiliaryPort = occupied.port!; + const publicPort = await findAvailablePort(0, "127.0.0.1"); + writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ + port: publicPort, hostname: "127.0.0.1", providers: {}, defaultProvider: "openai", + codexAutoStart: false, syncResumeHistory: false, + clientIntegrations: { codex: false, grok: false, "claude-desktop": false }, + claudeCode: { systemEnv: false }, + ...(listener === "hub.managementIngress" + ? { runtimeRole: "hub", hub: { managementIngress: { enabled: true, port: auxiliaryPort } } } + : { unauthenticatedLoopbackListener: { enabled: true, port: auxiliaryPort } }), + })); + const child = Bun.spawn([process.execPath, repoPath("src/cli/index.ts"), "start", ...(pinned ? ["--port", String(publicPort)] : [])], { + cwd: root, + env: { HOME: home, USERPROFILE: home, OPENCODEX_HOME: ocxHome, CODEX_HOME: codexHome, + PATH: process.env.PATH ?? "", NO_PROXY: "127.0.0.1,localhost" }, + stdout: "pipe", stderr: "pipe", + }); + let timedOut = false; + const deadline = setTimeout(() => { timedOut = true; child.kill(); }, DEADLINE); + try { + const [code, stdout, stderr] = await Promise.all([ + child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), + ]); + const output = stdout + stderr; + expect(timedOut, "CLI must exit on its own before the watchdog").toBe(false); + expect(code).not.toBe(0); + expect(output).toContain(`${listener} at 127.0.0.1:${auxiliaryPort}`); + expect(output).not.toContain("picking another"); + expect(output).not.toContain("waiting to retry the same port"); + const rebound = Bun.serve({ port: publicPort, hostname: "127.0.0.1", fetch: () => new Response("free") }); + await rebound.stop(true); + } finally { + clearTimeout(deadline); + if (child.exitCode === null) { child.kill(); await child.exited; } + await occupied.stop(true); + removeTreeWithRetry(root); + } + }, DEADLINE + 10_000); + } +} diff --git a/tests/clients/client-connect.test.ts b/tests/clients/client-connect.test.ts index 8b724bc438..9294167743 100644 --- a/tests/clients/client-connect.test.ts +++ b/tests/clients/client-connect.test.ts @@ -21,6 +21,28 @@ const repoRoot = findRepoRoot(); const CLIENT_FIXTURE_FAILURE_CATEGORIES = ["module_load", "config_setup", "desktop_setup", "scenario", "child_failed"] as const; type ClientFixtureFailureCategory = typeof CLIENT_FIXTURE_FAILURE_CATEGORIES[number]; +const TRANSACTION_PHASES = ["module_load", "module_ready", "connect_entered", "connect_resolved", "connect_rejected", "state_read", "result_published"] as const; +type TransactionProgress = { phase: typeof TRANSACTION_PHASES[number]; elapsedMs: number }; + +/** Only this fixed diagnostic envelope may reach a parent-side failure message. */ +function transactionProgress(stderr: unknown): TransactionProgress | undefined { + if (typeof stderr !== "string") return undefined; + const tail = stderr.slice(-8192); + for (const line of tail.split("\n").reverse()) { + if (!line.startsWith('{"clientTransactionPhase":')) continue; + try { + const row: unknown = JSON.parse(line); + if (!row || typeof row !== "object" || Array.isArray(row)) continue; + const { clientTransactionPhase, elapsedMs } = row as Record; + const phase = TRANSACTION_PHASES.find(value => value === clientTransactionPhase); + if (phase && typeof elapsedMs === "number" && Number.isSafeInteger(elapsedMs) && elapsedMs >= 0 && elapsedMs <= 600_000) { + return { phase, elapsedMs }; + } + } catch { /* Ignore raw child output and malformed markers. */ } + } + return undefined; +} + class ClientStateProbeError extends Error { constructor( readonly pid: number, @@ -28,9 +50,10 @@ class ClientStateProbeError extends Error { readonly signal: NodeJS.Signals | null, readonly timedOut: boolean, readonly failureCategory?: ClientFixtureFailureCategory, + readonly progress?: TransactionProgress, ) { // Do not include the child script, environment, stdout or stderr in failure output. - super(`Client state probe ${timedOut ? "timed out" : "failed"} (status=${status}, signal=${signal}${failureCategory ? `, category=${failureCategory}` : ""})`); + super(`Client state probe ${timedOut ? "timed out" : "failed"} (status=${status}, signal=${signal}${failureCategory ? `, category=${failureCategory}` : ""}${progress ? `, phase=${progress.phase}, elapsedMs=${progress.elapsedMs}` : ""})`); this.name = "ClientStateProbeError"; } } @@ -339,7 +362,12 @@ function runTransactionScenario( mkdirSync(join(opencodexHome, "config-mutation.sqlite")); } const script = options.script ?? ` - const { existsSync, readFileSync } = require("node:fs"); + const { existsSync, readFileSync, writeSync } = require("node:fs"); + const transactionStarted = Date.now(); + const markTransaction = phase => writeSync(2, JSON.stringify({ + clientTransactionPhase: phase, elapsedMs: Math.max(0, Date.now() - transactionStarted), + }) + "\\n"); + markTransaction("module_load"); const { createHash } = require("node:crypto"); const { connectClient, disconnectClient } = require("./src/client/connect"); const { readClientConnectionState } = require("./src/client/state"); @@ -348,6 +376,7 @@ function runTransactionScenario( const { DEFAULT_CATALOG_PATH } = require("./src/codex/paths"); const stage = ${JSON.stringify(stage)}; const { setPersistedConfigMutationBeforeCommitForTests } = require("./src/config"); + markTransaction("module_ready"); let commitFaultTriggered = false; const catalog = '{"models":[]}'; const etag = '"sha256-' + createHash("sha256").update(catalog).digest("base64url") + '"'; @@ -373,7 +402,9 @@ function runTransactionScenario( (async () => { let connected = null; let error = null; + let coordinatorUnavailable = false; try { + markTransaction("connect_entered"); connected = await connectClient({ serverUrl: "https://hub.example.test", credential: { kind: "admin", value: credential }, @@ -387,7 +418,17 @@ function runTransactionScenario( }); return new Date("2026-08-28T00:00:00.000Z"); }, lifecycleLockDeps: { lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" } }); - } catch (cause) { error = cause instanceof Error ? cause.message : String(cause); } + markTransaction("connect_resolved"); + } catch (cause) { + markTransaction("connect_rejected"); + error = cause instanceof Error ? cause.message : String(cause); + const seen = new Set(); + for (let current = cause, depth = 0; current && typeof current === "object" && depth < 32 && !seen.has(current); current = current.cause, depth++) { + seen.add(current); + if (current.code === "CONFIG_MUTATION_LOCK_UNAVAILABLE") coordinatorUnavailable = true; + } + } + markTransaction("state_read"); const beforeDisconnect = readClientConnectionState(); // The hub-state cache is derived from THIS connection; disconnect has to take it with it. let hubStateCacheBefore = false; @@ -407,7 +448,8 @@ function runTransactionScenario( if ((stage === "success" || stage === "prior-catalog") && connected) disconnected = await disconnectClient({}, { lifecycleLockDeps: { lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" } }); const catalogAfter = existsSync(DEFAULT_CATALOG_PATH) ? readFileSync(DEFAULT_CATALOG_PATH, "utf8") : null; const hubStateCacheAfter = existsSync(hubStateCachePath()); - console.log(JSON.stringify({ connected, error, beforeDisconnect, artifacts, disconnected, catalogAfter, hubStateCacheBefore, hubStateCacheAfter, after: readClientConnectionState(), calls, commitFaultTriggered })); + writeSync(1, JSON.stringify({ connected, error, coordinatorUnavailable, beforeDisconnect, artifacts, disconnected, catalogAfter, hubStateCacheBefore, hubStateCacheAfter, after: readClientConnectionState(), calls, commitFaultTriggered }) + "\\n"); + markTransaction("result_published"); })(); `; const cleanup = () => { @@ -426,12 +468,13 @@ function runTransactionScenario( timeout: options.timeoutMs ?? INTERNAL_DEADLINE_MS, killSignal: "SIGKILL", }); + const progress = transactionProgress(result.stderr); if (result.error || result.status !== 0 || result.signal !== null) { - throw new ClientStateProbeError(result.pid, result.status, result.signal, (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT"); + throw new ClientStateProbeError(result.pid, result.status, result.signal, (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT", undefined, progress); } let parsed: Record; try { parsed = JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "{}"); } - catch { throw new ClientStateProbeError(result.pid, result.status, result.signal, false); } + catch { throw new ClientStateProbeError(result.pid, result.status, result.signal, false, undefined, progress); } return { status: result.status, stderr: result.stderr, parsed, configBytes: readFileSync(configPath, "utf8"), cleanup }; } catch (error) { try { cleanup(); } @@ -441,6 +484,29 @@ function runTransactionScenario( } describe("connect transaction and offline disconnect", () => { + test("transaction diagnostics retain only allowlisted phase and bounded elapsed evidence", () => { + for (const missing of [undefined, null, { secret: "private-marker-value" }]) { + expect(transactionProgress(missing)).toBeUndefined(); + } + const valid = '{"clientTransactionPhase":"connect_entered","elapsedMs":12,"secret":"private-marker-value"}'; + for (const invalid of [ + '{"clientTransactionPhase":"private-marker-value","elapsedMs":1}', + '{"clientTransactionPhase":"connect_entered","elapsedMs":"private-marker-value"}', + '{"clientTransactionPhase":"connect_entered","elapsedMs":-1}', + '{"clientTransactionPhase":"connect_entered","elapsedMs":600001}', + '{"clientTransactionPhase":"connect_entered","elapsedMs":1.5}', + '{"clientTransactionPhase":"connect_entered",', + ]) { + expect(transactionProgress(invalid)).toBeUndefined(); + const progress = transactionProgress(`${valid}\n${invalid}`); + expect(progress).toEqual({ phase: "connect_entered", elapsedMs: 12 }); + const failure = new ClientStateProbeError(123, null, "SIGKILL", true, undefined, progress); + expect(failure.message).toContain("phase=connect_entered, elapsedMs=12"); + expect(failure.message).not.toContain("private-marker-value"); + } + expect(transactionProgress(valid + "\n" + "x".repeat(8192))).toBeUndefined(); + }); + test("transaction fixture stops a child retained after valid output", async () => { const proofHome = mkdtempSync(join(tmpdir(), "ocx-transaction-child-proof-")); const markerPath = join(proofHome, "child-started.json"); @@ -451,6 +517,8 @@ describe("connect transaction and offline disconnect", () => { pid: process.pid, home: process.env.OPENCODEX_HOME, codexHome: process.env.CODEX_HOME, })); fs.writeSync(1, '{"ok":true}\\n'); + fs.writeSync(2, '{"clientTransactionPhase":"result_published","elapsedMs":1}\\n'); + fs.writeSync(2, '{"clientTransactionPhase":"private-child-secret","elapsedMs":2}\\n'); setTimeout(() => { fs.writeFileSync(${JSON.stringify(naturalExitPath)}, "exited"); }, 5_000); `; let run: Awaited> | undefined; @@ -463,6 +531,8 @@ describe("connect transaction and offline disconnect", () => { expect(failure).toBeInstanceOf(ClientStateProbeError); if (!(failure instanceof ClientStateProbeError)) throw new Error("Expected bounded transaction child failure"); expect(failure.timedOut).toBe(true); + expect(failure.progress).toEqual({ phase: "result_published", elapsedMs: 1 }); + expect(failure.message).not.toContain("private-child-secret"); expect(existsSync(naturalExitPath)).toBe(false); const proof = JSON.parse(readFileSync(markerPath, "utf8")) as { pid: number; home: string; codexHome: string }; expect(proof.pid).toBe(failure.pid); @@ -519,6 +589,7 @@ describe("connect transaction and offline disconnect", () => { try { expect(run.status).toBe(0); expect(run.parsed.connected).toBeNull(); + expect(run.parsed.coordinatorUnavailable).toBe(true); expect(run.parsed.calls).toEqual([]); expect(run.parsed.artifacts).toEqual({ token: false, catalog: false, credentialZeroed: true }); } finally { run.cleanup(); } diff --git a/tests/config/config-load-degrade.test.ts b/tests/config/config-load-degrade.test.ts index 4d8cca68f7..2ca6ab258a 100644 --- a/tests/config/config-load-degrade.test.ts +++ b/tests/config/config-load-degrade.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getConfigPath, getDefaultConfig, loadConfig, + readConfigDiagnostics, saveConfig, validateConfigCandidate, } from "../../src/config"; @@ -139,3 +140,84 @@ test("Fast rows default on for fresh and omitted config; explicit false and malf expect(loaded.providers.xai.note).toBe("keep me"); } }); + + +test.each([ + { enabled: "true" }, + { enabled: true, port: 70000 }, + "secret-shaped-malformed-listener-value", +])("malformed optional listeners warn without discarding unrelated settings: %j", listener => { + const config = { ...candidate(undefined), + apiKeys: [{ id: "preserved", name: "preserved", key: "fixture-key", createdAt: "2026-01-01" }], + unauthenticatedLoopbackListener: listener, + hub: { managementIngress: listener }, + }; + const bytes = JSON.stringify(config); + writeFileSync(getConfigPath(), bytes); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const loaded = loadConfig(); + expect(loaded.providers.xai.note).toBe("keep me"); + expect(loaded.apiKeys?.[0]?.id).toBe("preserved"); + expect(loaded.unauthenticatedLoopbackListener).toBeUndefined(); + expect(loaded.hub?.managementIngress).toBeUndefined(); + const messages = warn.mock.calls.flat().join("\n"); + expect(messages).toContain("unauthenticatedLoopbackListener ignored"); + expect(messages).toContain("hub.managementIngress ignored"); + expect(messages).not.toContain("secret-shaped-malformed-listener-value"); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.warnings?.join("\n")).toContain("unauthenticatedLoopbackListener ignored"); + expect(diagnostics.warnings?.join("\n")).toContain("hub.managementIngress ignored"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + } finally { warn.mockRestore(); } +}); + +test.each([undefined, { enabled: false }])("absent or disabled listeners do not produce degradation warnings: %j", listener => { + writeFileSync(getConfigPath(), JSON.stringify({ ...candidate(undefined), + unauthenticatedLoopbackListener: listener, hub: { managementIngress: listener }, + })); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + loadConfig(); + const messages = warn.mock.calls.flat().join("\n"); + expect(messages).not.toContain("Listener ignored"); + expect(messages).not.toContain("managementIngress ignored"); + } finally { warn.mockRestore(); } +}); + +test("salvaged diagnostics retain listener warnings alongside the routing error", () => { + const bytes = JSON.stringify({ ...candidate(undefined), + routingProfiles: { bad: { candidates: [] } }, + unauthenticatedLoopbackListener: { enabled: "true" }, + hub: { managementIngress: { enabled: true, port: 70000 } }, + }); + writeFileSync(getConfigPath(), bytes); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain("routingProfiles"); + expect(diagnostics.config.providers.xai.note).toBe("keep me"); + expect(diagnostics.warnings?.join("\n")).toContain("unauthenticatedLoopbackListener ignored"); + expect(diagnostics.warnings?.join("\n")).toContain("hub.managementIngress ignored"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); +}); + + +test("valid ingress is not blamed when a malformed hub sibling disables the hub block", () => { + const bytes = JSON.stringify({ ...candidate(undefined), hub: { + dataPublicOrigin: "not-an-origin", managementIngress: { enabled: true, port: 12345 }, + } }); + writeFileSync(getConfigPath(), bytes); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const loaded = loadConfig(); + expect(loaded.hub).toBeUndefined(); + expect(loaded.providers.xai.note).toBe("keep me"); + const messages = warn.mock.calls.flat().join("\n"); + expect(messages).toContain("hub.dataPublicOrigin"); + expect(messages).not.toContain("hub.managementIngress ignored"); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.warnings?.join("\n")).toContain("hub.dataPublicOrigin"); + expect(diagnostics.warnings?.join("\n")).not.toContain("hub.managementIngress ignored"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + } finally { warn.mockRestore(); } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0118a80600..05eb0a346b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -195,6 +195,7 @@ "cli-registry.test.ts": "cli", "cli-restart-health.test.ts": "cli", "cli-restore-back.test.ts": "cli", + "cli-start-auxiliary-bind.test.ts": "cli", "cli-start-journal-order.test.ts": "cli", "cli-status-hub-state.test.ts": "cli", "cli-status-json.test.ts": "cli", @@ -382,6 +383,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", @@ -783,6 +785,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", @@ -798,6 +801,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/cursor/cursor-adapter.test.ts b/tests/providers/cursor/cursor-adapter.test.ts index a86df7c243..9a03be37cd 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,392 @@ 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", + _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); + }); + + 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("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; + let emitPartial = false; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run() { + attempts += 1; + if (emitPartial) yield { type: "text", text: "partial" } satisfies CursorServerMessage; + throw bareOverflowError(); + }, + writeClient() {}, + }), + }); + + 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)); + + 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 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); + } + 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..ea441e8ad4 --- /dev/null +++ b/tests/providers/cursor/cursor-continuity-retention.test.ts @@ -0,0 +1,69 @@ +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++) { + 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); + }); +}); 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"); +}); diff --git a/tests/server/loopback-listener-integration.test.ts b/tests/server/loopback-listener-integration.test.ts index 3b616d822e..3664b5f439 100644 --- a/tests/server/loopback-listener-integration.test.ts +++ b/tests/server/loopback-listener-integration.test.ts @@ -17,6 +17,7 @@ import { saveConfig } from "../../src/config"; import { startServer } from "../../src/server"; import { runListenerShutdown } from "../../src/server/lifecycle"; import { + AuxiliaryListenerBindError, findAvailablePort, PortUnavailableError, setEphemeralPortAllocatorForTests, @@ -182,17 +183,21 @@ describe("hub management ingress", () => { }); test("a failed management bind rolls back both earlier listeners", async () => { - const managementPort = await freePort(); - const loopbackPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); - const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: loopbackPort }); const squatter = Bun.serve({ - port: managementPort, + port: 0, hostname: "127.0.0.1", fetch: () => new Response("occupied"), }); + const managementPort = squatter.port!; + const loopbackPort = await freePort(); + const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: loopbackPort }); saveConfig(hubIngressConfig(managementPort, loopbackPort)); try { - expect(() => startServer(publicPort)).toThrow(); + let failure: unknown; + try { startServer(publicPort); } catch (error) { failure = error; } + expect(failure).toBeInstanceOf(AuxiliaryListenerBindError); + expect(failure).toMatchObject({ listener: "hub.managementIngress", port: managementPort, hostname: "127.0.0.1" }); + expect((failure as Error).cause).toBeDefined(); for (const port of [publicPort, loopbackPort]) { const rebound = Bun.serve({ port, hostname: "127.0.0.1", fetch: () => new Response("ok") }); await rebound.stop(true); @@ -759,7 +764,11 @@ describe("unauthenticated loopback listener", () => { }); saveConfig(baseConfig(loopbackPort)); try { - expect(() => startServer(publicPort)).toThrow(); + let failure: unknown; + try { startServer(publicPort); } catch (error) { failure = error; } + expect(failure).toBeInstanceOf(AuxiliaryListenerBindError); + expect(failure).toMatchObject({ listener: "unauthenticatedLoopbackListener", port: loopbackPort, hostname: "127.0.0.1" }); + expect((failure as Error).cause).toBeDefined(); const rebound = Bun.serve({ port: publicPort, diff --git a/tests/server/ports.test.ts b/tests/server/ports.test.ts index 6471244c56..f0acfa250f 100644 --- a/tests/server/ports.test.ts +++ b/tests/server/ports.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createServer, type Server } from "node:net"; import { pathToFileURL } from "node:url"; -import { findAvailablePort, isAddrInUse, isPortAvailable, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../../src/server/ports"; +import { AuxiliaryListenerBindError, findAvailablePort, isAddrInUse, isPortAvailable, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../../src/server/ports"; import { repoPath, repoRoot } from "../helpers/repo-root"; // Prototype overrides exist only inside the disposable child process. @@ -233,3 +233,14 @@ describe("port selection", () => { expect(await isPortAvailable(54321, "192.0.2.1")).toBe(false); }); }); + + +test("auxiliary bind diagnostics preserve non-conflict causes without claiming a busy port", () => { + const cause = Object.assign(new Error("permission denied"), { code: "EACCES" }); + const failure = new AuxiliaryListenerBindError("hub.managementIngress", 12345, "127.0.0.1", cause); + expect(failure.cause).toBe(cause); + expect(failure.message).toContain("hub.managementIngress at 127.0.0.1:12345"); + expect(failure.message).not.toContain("busy"); + expect(isAddrInUse(failure)).toBe(false); + expect(isAddrInUse({ code: "EADDRINUSE" })).toBe(true); +}); diff --git a/tests/server/server-live.test.ts b/tests/server/server-live.test.ts index f0fc6c94cd..8f0a3ebf5d 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 }); diff --git a/tests/web-search/web-search.test.ts b/tests/web-search/web-search.test.ts index e182308a0d..b2995a9e12 100644 --- a/tests/web-search/web-search.test.ts +++ b/tests/web-search/web-search.test.ts @@ -133,6 +133,163 @@ 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, 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), + forwardProvider, + hostedTool: { type: "web_search" }, + 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!); + } + + 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[] = []; + 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([ + actualSearch, + [{ 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 ... + 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"))) + .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 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 () => { + 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, 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 } }; + 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"); + }); + } + } + + 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 = {