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/devlog/_plan/260913_60plus_skipci_stack/screenshots/pairing-hub-1440.png b/devlog/_plan/260913_60plus_skipci_stack/screenshots/pairing-hub-1440.png new file mode 100644 index 0000000000..66ed8a04a3 Binary files /dev/null and b/devlog/_plan/260913_60plus_skipci_stack/screenshots/pairing-hub-1440.png differ 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/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 819aa3f360..0fd021ad65 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -256,6 +256,19 @@ Admission and retention are deliberately narrow: fail-closed error; client cancellation returns 499. Neither path forwards ciphertext to the routed provider. +Recovery accepts one consecutive run of up to 32 complete Fernet-shaped encrypted parts, with +at most 2 MiB of combined ciphertext. Parts retain their order and boundaries in one authenticated +request. Cache identity includes the sequence; the original input is revalidated before assignment +replacement. HTTP failures retain the existing bounded diagnostic reason and do not trigger an +internal retry. + +Split tokens are not reconstructed for recovery. A bounded run whose exact concatenation has +Fernet structure stays classified as ciphertext through plaintext-slot normalization. If the task +has no independent readable text, it fails closed without a recovery or routed-provider request. +Independent readable text retains the existing mixed-content policy. Other fragment representations +remain unsupported; this does not establish general token-split recovery or upstream multipart +fidelity. + ### Threat model This path assumes the local native Codex caller already holds a valid ChatGPT credential and that diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index c9ac63bee9..6fd9561221 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -32,6 +32,30 @@ the same serialized request and records the recovery in Logs. Other 400 errors, custom destinations, cancellations and repeated upload rejections remain failures. This does not retry filtered model responses or interrupted streams. +## 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 644c5f5610..1e99ed6f73 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 bee7d5a119..4a7833a0b4 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2604,6 +2604,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 7771d58b93..96e30de557 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2679,6 +2679,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 0c2583efdb..0dc86dbaae 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2592,6 +2592,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 47bfd3bf7a..97b5dff281 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2625,6 +2625,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 8de8772e0e..0957274d6b 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2626,6 +2626,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 a5f530b9d2..464c71d7ac 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2627,6 +2627,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 eb07fe4c80..3e3c12358f 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2627,6 +2627,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 f681fde7d7..a0d0767dc3 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2590,6 +2590,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 9b5f76db01..da66e4f8da 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2625,6 +2625,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/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx index a62bc47eea..2aa28acb10 100644 --- a/gui/src/pages/dashboard-overview-sections.tsx +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -443,13 +443,13 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) { sidecar, sidecarSaving, sidecarModels, visionModels, models, saveSidecar, shadowCall, shadowCallSaving, shadowCallHelpTriggerRef, shadowCallHelpOpen, setShadowCallHelpOpen, saveShadowCall, } = d; - const visionEnabled = sidecar?.vision.enabled !== false; - const visionModel = visionEnabled ? (sidecar?.vision.model ?? "gpt-5.6-luna") : ""; - const persistedVisionReasoning = sidecar?.vision.reasoning ?? "low"; + const visionEnabled = sidecar?.vision?.enabled !== false; + const visionModel = visionEnabled ? (sidecar?.vision?.model ?? "gpt-5.6-luna") : ""; + const persistedVisionReasoning = sidecar?.vision?.reasoning ?? "low"; const visionLadder = visionReasoningLadder(models, visionModel); const visionReasoning = clampVisionReasoningToLadder(visionLadder, persistedVisionReasoning); - const serverMaxDescriptions = String(sidecar?.vision.maxDescriptionsPerTurn ?? VISION_MAX_DESCRIPTIONS_DEFAULT); - const serverTimeoutMs = String(sidecar?.vision.timeoutMs ?? VISION_TIMEOUT_MS_DEFAULT); + const serverMaxDescriptions = String(sidecar?.vision?.maxDescriptionsPerTurn ?? VISION_MAX_DESCRIPTIONS_DEFAULT); + const serverTimeoutMs = String(sidecar?.vision?.timeoutMs ?? VISION_TIMEOUT_MS_DEFAULT); const [maxDraft, setMaxDraft] = useState(null); const [timeoutDraft, setTimeoutDraft] = useState(null); const [maxInvalid, setMaxInvalid] = useState(false); @@ -468,7 +468,7 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) { } setMaxInvalid(false); setMaxDraft(null); - if (parsed === (sidecar?.vision.maxDescriptionsPerTurn ?? VISION_MAX_DESCRIPTIONS_DEFAULT)) return; + if (parsed === (sidecar?.vision?.maxDescriptionsPerTurn ?? VISION_MAX_DESCRIPTIONS_DEFAULT)) return; void saveSidecar(visionMaxDescriptionsPatch(parsed)); }; @@ -481,7 +481,7 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) { } setTimeoutInvalid(false); setTimeoutDraft(null); - if (parsed === (sidecar?.vision.timeoutMs ?? VISION_TIMEOUT_MS_DEFAULT)) return; + if (parsed === (sidecar?.vision?.timeoutMs ?? VISION_TIMEOUT_MS_DEFAULT)) return; void saveSidecar(visionTimeoutPatch(parsed)); }; @@ -561,7 +561,7 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) {