From 7cccab33ca4a90626d6d693029f36231f02abe6a Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:45:24 +0900 Subject: [PATCH 1/4] fix(dashboard): guide hub pairing without restarting healthy clients Share browser session readiness with the shell, explicitly refresh retained resources after pairing, distinguish authentication/permission/network/shape failures, and preserve labelled stale data only for non-auth failures. Show hub identity and current-origin pairing command with copy and cancellation feedback. Local suites NOT RUN; hosted build and rendered review follow. --- devlog/_plan/260912_operations/000_plan.md | 34 ++++++++ .../260912_operations/001_roadmap_audit.md | 7 ++ devlog/_plan/260912_operations/010_update.md | 11 +++ .../_plan/260912_operations/020_listeners.md | 13 +++ devlog/_plan/260912_operations/030_totals.md | 11 +++ .../260912_operations/040_client_usage.md | 13 +++ devlog/_plan/260912_operations/050_pairing.md | 29 +++++++ .../_plan/260912_operations/060_transport.md | 7 ++ .../260912_operations/070_verification.md | 9 ++ .../src/content/docs/guides/remote-hub.md | 6 ++ .../src/content/docs/guides/web-dashboard.md | 6 ++ .../src/content/docs/ko/guides/remote-hub.md | 6 ++ .../content/docs/ko/guides/web-dashboard.md | 6 ++ gui/src/App.tsx | 21 ++++- gui/src/api.ts | 16 ++-- gui/src/connect-pairing-transport.ts | 39 +++++++-- gui/src/connect-pairing.ts | 45 +++++++--- gui/src/i18n/de.ts | 11 +++ gui/src/i18n/en.ts | 11 +++ gui/src/i18n/fr.ts | 11 +++ gui/src/i18n/ja.ts | 11 +++ gui/src/i18n/ko.ts | 11 +++ gui/src/i18n/ru.ts | 11 +++ gui/src/i18n/tr.ts | 11 +++ gui/src/i18n/zh-TW.ts | 11 +++ gui/src/i18n/zh.ts | 11 +++ gui/src/pages/Dashboard.tsx | 20 +++-- gui/src/pages/dashboard-core-poll.ts | 24 +++++- gui/src/pages/use-dashboard-data.ts | 21 ++--- gui/tests/connect-pairing.test.ts | 84 ++++++++++++++++++- gui/tests/dashboard-connection-state.test.ts | 34 ++++++++ structure/design-methodology.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/overview.md | 2 + 35 files changed, 519 insertions(+), 50 deletions(-) create mode 100644 devlog/_plan/260912_operations/000_plan.md create mode 100644 devlog/_plan/260912_operations/001_roadmap_audit.md create mode 100644 devlog/_plan/260912_operations/010_update.md create mode 100644 devlog/_plan/260912_operations/020_listeners.md create mode 100644 devlog/_plan/260912_operations/030_totals.md create mode 100644 devlog/_plan/260912_operations/040_client_usage.md create mode 100644 devlog/_plan/260912_operations/050_pairing.md create mode 100644 devlog/_plan/260912_operations/060_transport.md create mode 100644 devlog/_plan/260912_operations/070_verification.md create mode 100644 gui/tests/dashboard-connection-state.test.ts diff --git a/devlog/_plan/260912_operations/000_plan.md b/devlog/_plan/260912_operations/000_plan.md new file mode 100644 index 0000000000..f8ad96b84b --- /dev/null +++ b/devlog/_plan/260912_operations/000_plan.md @@ -0,0 +1,34 @@ +# Operations delivery roadmap + +Operators need accurate startup failures, safe update retry cleanup, readable usage, and actionable connected-client guidance. This unit carries the remaining reviewed contributions and repairs their current callers. The original stop-refusal implementation is already present on dev; it is not applied twice. + +Loop: satisfy-spec HOTL, triggered by the operations lane assignment. Goal: reviewable PRs with final-tip hosted evidence and a durable handoff. Non-goals: merging, closing originals, releases, live service/config changes, local product suites/build/typecheck/install. Scope: this managed worktree and explicitly authorized GitHub PR writes; existing credentials only. No user token/time/agent-count cap. Stop only after every disposition, acceptance row and final-tip result is recorded, or an actual unavailable external gate is documented. Memory artifact: this unit and ignored .tmp/operations/handoff.md. Escalation: real tool/access denial or scope beyond assigned issues; main owns code and decisions. Native architect role is unavailable; supported inherited design reviews plus separate A audits follow explicit user direction. + +## Work-phase map + +| ID | Document | Outcome | Dependency | +| --- | --- | --- | --- | +| roadmap | 000 + all decade docs | Docs-only source-grounded roadmap | none | +| update | 010_update.md | Retire observed exited pinned children (#4185) | roadmap | +| listeners | 020_listeners.md | Name auxiliary bind failures and malformed edits (#4236 residual) | roadmap | +| totals | 030_totals.md | Keep readable usage and disclose omissions (#4111) | roadmap | +| client-usage | 040_client_usage.md | Hub usage scoped to connected client (#4205) | roadmap; preserve totals contract if shared | +| pairing | 050_pairing.md | Hub identity and origin-specific browser authentication (#4206/#4208) | roadmap | +| transport | 060_transport.md | Reviewed local management catalog read (#4315/#4317) | roadmap | +| verification | 070_verification.md | Exact tips, hosted results, credits, handoff | all implementations | + +Independent changes use independent branches/PRs from the fetched dev baseline plus the common roadmap checkpoint. Only actual shared-code dependencies become an ordinary manual chain. No native stack registration. Intermediate auto-CI stays enabled; final cumulative tips are the acceptance unit. + +## Source disposition + +- #4170 OPEN at 4d72ef010363b80cd78f65148a5228d6797a3117, but dev contains 1ada8f5ff1 and further refusalNextStep behavior in src/lib/process-control.ts. No duplicate carry. Missing-message incident and actual scheduler behavior are not proven resolved by wording. +- #4185 OPEN at 2602f3ceca4b93237436911dcd8dffc35b3b5e57; current src/update/job.ts still uses lastChild.pid alone. +- #4111 OPEN at 2f07acb58b3e73f48cea38334f301b430a8634cd. Readable totals differ from connected-client routing. +- #4317 OPEN / CHANGES_REQUESTED at 27f577aa795f3b968c070a50eded48411bd7e985. Independent security review and own caller-complete patch required. Sensitive analysis stays in ignored scratch. +- #4236 latest public comment identifies only auxiliary listener bind diagnosis and malformed edit reporting as outstanding. #4249/#4250/#4251/#4252/#4254/#4255 are already landed and must not be replayed. + +## Evidence policy + +Local suites of every size, typecheck, builds and install: NOT RUN by explicit user instruction. Regression code goes to GitHub-hosted Cross-platform CI, whose pull_request trigger has no base filter (.github/workflows/ci.yml:3). Docs consistency uses git diff --check and file-map inspection, not claimed test coverage. Source review is not execution. Each final source SHA is paired with its actual run ID/URL/conclusion; skipped/cancelled runs are not passing proof. Every C/D records this limitation and defers behavioral acceptance to final hosted results. + +Existing tree conventions: src/cli owns commands, src/server owns listener/management boundaries, src/update owns update retries, gui/src owns React presentation, tests mirrors domains, structure/manifest.json maps owning docs. No new dependencies, service layer, or settings are needed. diff --git a/devlog/_plan/260912_operations/001_roadmap_audit.md b/devlog/_plan/260912_operations/001_roadmap_audit.md new file mode 100644 index 0000000000..d99db8768d --- /dev/null +++ b/devlog/_plan/260912_operations/001_roadmap_audit.md @@ -0,0 +1,7 @@ +# Roadmap audit outcome + +Independent design reviewers accepted the update, listener, usage, pairing and local-catalog designs after reflection. Native architect selection was unavailable; user-directed inherited model review was used without a native-role claim. Two reflection calls initially reported model capacity errors; same-handle retries completed. + +Independent A reviewer identified nested managementIngress degradation hidden by schema catch and an incomplete CLI regression path. Both were folded in and re-audited. Final verdict: GO-WITH-FIXES (blockers=0); remaining deps.managementOrigin wording corrected before lock. This audit certifies the plan only, not implementation or test behavior. + +The first cycle is docs-only. All eight roadmap files exist, source/test paths were reviewed, and git diff --cached --check exits zero. Product suites/build/typecheck/install NOT RUN by explicit instruction. Next direction: implement the observed-child cleanup slice from 010, then the independent listener slice. Final-tip hosted CI owns behavior acceptance. No retired update architecture or already-carried stop implementation is replayed. diff --git a/devlog/_plan/260912_operations/010_update.md b/devlog/_plan/260912_operations/010_update.md new file mode 100644 index 0000000000..53cb5d8719 --- /dev/null +++ b/devlog/_plan/260912_operations/010_update.md @@ -0,0 +1,11 @@ +# Pinned child retirement + +Class C4: process termination boundary. Dependency: roadmap. Reuse the existing RestartIo and restartAfterUpdate retry loop; no new process supervisor. + +MODIFY src/update/job.ts: RestartIo gains injectable spawnDetachedStartFn, preparePortForPinnedStartFn, waitForGhostListenClearFn and killProxyFn, matching existing functions. Replace Date.now in the pinned retry window with existing io.now. Replace both lastChild.pid-only kills with one closure accepting the ChildProcess: return when pid absent, exitCode non-null, or signalCode non-null; otherwise existing isAlive then kill. Capture the exact spawned child and attach once(exit) to clear lastChild only when lastChild === child. Keep healthy-probe early return. No persisted schema or serialization changes; these are process-local IO seams consumed in the retry loop only. + +MODIFY tests/update/update-job.test.ts: carry #4185 deterministic fake EventEmitter children through actual three retries, using injected clock and no real kill. Cases: successful exit, nonzero exit, signal exit, event retirement, live timeout cleanup, same-PID late old event, healthy last attempt. MODIFY tests/windows/windows-deploy-close-regressions.test.ts: replace obsolete exact PID-expression oracle with reference to behavior regression; keep wrapper ownership assertions. MODIFY structure/runtime.md to state observed-child retirement. Existing process code is otherwise unchanged. + +Exact starting patch: public PR #4185 head 2602f3ceca4b93237436911dcd8dffc35b3b5e57, reviewed source diff retained locally in .tmp/operations/pr-4185.diff. Before: numeric PID may remain after child exit. After: recorded exit/signal or matching exit event retires cleanup authority. This does not make all OS signalling atomic against PID reuse. + +Planned hosted activation checks (not yet executed): seven tests in update/update-job.test.ts exercise both cleanup sites. Local execution NOT RUN. Source check: both sites use the same child-aware closure; no unrelated test weakened. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>. diff --git a/devlog/_plan/260912_operations/020_listeners.md b/devlog/_plan/260912_operations/020_listeners.md new file mode 100644 index 0000000000..393ba74e8a --- /dev/null +++ b/devlog/_plan/260912_operations/020_listeners.md @@ -0,0 +1,13 @@ +# Auxiliary bind diagnosis and malformed edit reporting + +Class C3; dependency roadmap. Existing owners: src/server/ports.ts: isAddrInUse, src/server/index.ts: startup transaction, src/cli/index.ts: handleStart retry loop, src/config.ts: loadConfig warnings. + +MODIFY src/server/ports.ts: add AuxiliaryListenerBindError carrying listener name (unauthenticatedLoopbackListener or hub.managementIngress), port, hostname and original cause. Its message names the precise config key/address and directs correction of that effective auxiliary address (including a port-less companion). MODIFY src/server/index.ts: keep synchronous rollback of opened listeners, wrap only each auxiliary Bun.serve failure after rollback. MODIFY src/cli/index.ts: reject AuxiliaryListenerBindError before generic isAddrInUse retry logic. Public-listener EADDRINUSE retains soft/hard-pin behavior; auxiliary collisions never re-pick the public port. Fields are transient Error data, created by both auxiliary catches, consumed by CLI; no serialization. + +MODIFY src/config.ts: reuse load-time degraded-field warning family. When raw unauthenticatedLoopbackListener is present and validated value disappeared, emit field-specific warning without raw values; keep valid providers/keys and file bytes. The same pure warning is exposed by validFileConfigDiagnostics, so doctor/status also report degradation. Existing write-time loopbackListenerPortError stays strict. Whole-hub malformed blocks already warn, but nested hub.managementIngress can be silently swallowed before that check. Add a raw-versus-validated nested-ingress warning to the same owner and diagnostics. Cover string enabled and invalid port for BOTH listener blocks, preserving providers/file bytes. + +MODIFY existing tests/server/ports.test.ts and tests/server/loopback-listener-integration.test.ts : occupied loopback and management ports must report their config key and actual port, preserve cause and release every successful bind; non-EADDRINUSE errors identify bind failure without claiming the port is busy. Public conflict remains classified normally. Extend tests/config/config-load-degrade.test.ts and tests/server/loopback-listener-admission.test.ts with string enabled and out-of-range port: warning present, provider preserved, file unchanged. No local socket or service operation: tests only written, run remotely. + +MODIFY directly relevant structure/runtime.md, structure/config.md, structure/ops/service-and-sidecars.md and public hub/loopback guidance; link the canonical contract from other mapped ownership docs only where applicable. Review all conditional activation rows on hosted CI. Source inspection is not runtime proof. + +Design OPS-LIST-01..04 accepted with amendments. NEW tests/cli/cli-start-auxiliary-bind.test.ts, registered in both test-layout files, uses isolated CLI subprocess to cover soft and hard-pinned startup: failure names auxiliary key/address, exit nonzero, no public repick/wait branch. Existing management rollback fixture occupies management first then selects distinct public/loopback ports. Warning helper runs on all three load return paths plus read-only diagnostics; controls cover absent and valid-disabled entries and malformed secret-shaped input without echoing raw values. diff --git a/devlog/_plan/260912_operations/030_totals.md b/devlog/_plan/260912_operations/030_totals.md new file mode 100644 index 0000000000..71d2615772 --- /dev/null +++ b/devlog/_plan/260912_operations/030_totals.md @@ -0,0 +1,11 @@ +# Readable usage totals with explicit omissions + +Class C3; dependency roadmap. Adopt public #4111 final diff (2f07acb58b3e73f48cea38334f301b430a8634cd) after current-base and latest-review audit; preserve luvs01 credit. Source diff and metadata are in ignored .tmp/operations/pr-4111.diff/json, fetched directly from GitHub. + +MODIFY src/server/management/usage-aggregate-cache.ts: replace four oversizedRows throws with retained usageIncomplete boolean, set on full scan, OR on append, preserve in resultFrom; cache API-key snapshots with diagnostic. MODIFY api-key-usage.ts: keep readable accumulator output and attach usageIncomplete:true / usageIncompleteReason:oversized_rows instead of throwing. MODIFY logs-usage-routes.ts: serialize diagnostics on filtered and unfiltered summaries. MODIFY usage-summary-cache.ts CachedUsageSummary and oauth-account-routes.ts GET /api/keys to retain/serialize flags. Other IO/mutation errors still fail. + +MODIFY src/cli/usage-report.ts: warnings precede totals/no-match branch; incomplete no-match says skipped records may match. MODIFY gui/src/usage-summary-resource.ts shared optional diagnostic type; NEW components/usage-incomplete-notice.tsx; extend consumers Usage, dashboard overview, Models, AddProviderModal, ProviderWorkspaceShell, ApiKeysWorkspace/ListPanel and ApiKeys. Incomplete keys do not claim inactivity; warnings survive consumer caching. Add all locale keys. Full field chain: scanner oversizedRows -> retained aggregate boolean/API key snapshot -> route JSON/cache -> shared GUI/CLI input types -> every totals/ranking/key activity consumer. + +MODIFY existing tests/cli/cli-usage-report.test.ts, tests/server/api-usage.test.ts, tests/server/api-key-attribution.test.ts, tests/usage/usage-aggregate-cache.test.ts and GUI usage/custom-range/model-picker/key-workspace tests; NEW gui/tests/usage-incomplete-consumers.test.tsx. Activation: good + oversized + good rows yields readable sums and warning; append oversized sticky flag, full clean rewrite clears it, missing filter matches stays uncertain, loading/stale consumers retain warning. Do not turn IO errors into zero totals. + +MODIFY structure/gui-and-management-api.md and relevant mapped contract pointers; public management API, CLI agents and web-dashboard guides in all existing translated paths from original diff. Hosted full CI and dashboard evidence certify final tip; local suites/build/typecheck NOT RUN. This does not implement hub client-scoped CLI usage (#4205). diff --git a/devlog/_plan/260912_operations/040_client_usage.md b/devlog/_plan/260912_operations/040_client_usage.md new file mode 100644 index 0000000000..389c303f8b --- /dev/null +++ b/devlog/_plan/260912_operations/040_client_usage.md @@ -0,0 +1,13 @@ +# Connected client usage + +Class C4 for credential scope; dependency roadmap and only shared usage contract if needed. Existing src/cli/observe.ts:155 usage currently calls runtimeRequest('/api/usage'), whose owner src/cli/runtime-api.ts always resolves a local endpoint. Connected machine listener does not expose that route. + +MODIFY observe.ts usage dispatch to inspect existing client connection state before choosing the endpoint; standalone keeps runtimeRequest unchanged. Reuse the existing client-to-hub request owner and add a dedicated client-authenticated /v1/usage read, with only the enrolled client credential. Fail closed on invalid/mismatched state. Retain range/surface/provider/model and inclusive custom-window options, JSON versus human output, endpoint error messages and hub key scope. Do not send the local admin token to the hub or expose global management usage. If the existing hub endpoint supports fewer selectors, reject unsupported options explicitly until it is extended with the same authenticated scope. + +MODIFY existing client/hub API owner only where the read contract requires it; extend CLI usage and hub admission regression owners. Activation: connected client succeeds with own-key data despite local /api/usage absence; second client data excluded; revoked/bad credentials refuse; standalone still uses local management; custom-window contract preserved. Exact file map: NEW src/server/hub-usage.ts route handler and src/remote/hub-usage.ts shared response contract; MODIFY src/server/index.ts dispatch near /v1/models, src/server/auth-cors.ts configured-key identity resolver, src/client/hub-client.ts authenticated client read, src/cli/observe.ts usage dispatch, src/cli/usage-report.ts source/scope label. The route requires explicit configured-key admission even on loopback, derives apiKeyId from that identity, rejects caller-selected key IDs, uses filtered aggregation and no hub-wide summary cache. It omits private account attribution from the client DTO. Authentication/route enforcement tier: server code; caller-selected IDs cannot override the authenticated key; local admin/host control remains a residual outside client isolation. Final layer: server authorization. No claim of protection against the host owner. No local credential provisioning or running client changes. + +MODIFY public connected-client/CLI usage guide and structure/runtime.md / gui-and-management-api.md canonical scope. Any pre-disclosure details stay in scratch. Hosted regressions only; local execution NOT RUN. + +Accepted design OPS-USAGE-02/03/04. NEW tests/server/hub-usage.test.ts and tests/clients/hub-usage.test.ts with entries in scripts/test-layout/layout.json explicit and tests/fixtures/test-layout-expected.json; NEW tests/cli/cli-usage-hub.test.ts. Tests use two client keys, loopback and remote admissions, invalid state, custom window, unsupported endpoint, bad response, expired/revoked credentials. Full implementation follows source confirmation before B. + +Reflection amendments: getFilteredUsageAggregate in src/server/management/usage-aggregate-cache.ts is the aggregation owner. Client DTO preserves #4111 incomplete flags; CLI suppresses advice to remove filters for account totals because that scope never exports accounts. Public files: docs-site/src/content/docs/guides/remote-hub.md and reference/cli/agents.md. All three new tests register in scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json. diff --git a/devlog/_plan/260912_operations/050_pairing.md b/devlog/_plan/260912_operations/050_pairing.md new file mode 100644 index 0000000000..8aad40d093 --- /dev/null +++ b/devlog/_plan/260912_operations/050_pairing.md @@ -0,0 +1,29 @@ +# Pending browser authentication guidance + +Class C3; dependency roadmap. Reuse existing connected-client state and browser-auth components. Target #4206 and #4208 together because both concern the same pending local-dashboard pairing journey. + +MODIFY owning dashboard pending-auth component and bootstrap state: distinguish a reachable connected machine awaiting hub browser authentication from a stopped standalone proxy. Show configured hub identity/origin, explain that machine enrollment and browser session are separate, offer the current origin-specific existing pairing/authentication action. Preserve revoked/expired/unreachable states and their existing retry actions; do not suggest ocx start while the local runtime is reachable. Derive the next action from current origin + configured hub instead of a hardcoded localhost URL. No credentials appear in visible copy/URLs. + +MODIFY all gui/src/i18n locale dictionaries with meaningful labels. Extend existing pending-auth/dashboard tests for local origin, remote hub origin, pending, authenticated, expired/revoked and unavailable standalone; positive browser auth transitions into connected dashboard. Exact files: gui/src/App.tsx, api.ts, pages/dashboard-core-poll.ts, pages/use-dashboard-data.ts and pages/Dashboard.tsx consume a classified authentication/error state instead of a boolean. Existing connect-pairing.ts and connect-pairing-transport.ts own hub identity and origin-specific action. Define the error classification in api.ts at response ingress; consume in polling and Dashboard; reset on authenticated success and pairing completion. No persistence/serialization for this UI state. Keep cached data with stale labeling when auth fails; do not erase a known hub into standalone offline. Public hub/browser-pairing guidance is updated with the same distinction. No service restart or live auth reconfiguration. + +Hosted component suite and screenshot artifact of the rendered pending state required for final delivery; local GUI tests/build NOT RUN. Static source or mockup is not rendered application evidence. + +Accepted OPS-PAIR-01/02. Cases include browser session expiry and post-pairing refresh, local and hub origin guidance, code versus API/admin-key explanation, and operator handoff text. Prefer existing component tests; new test files only where needed. + +Reflection amendments: reuse existing api.ts SESSION_UNAVAILABLE_EVENT and App sharedSessionReady; subscribe in App, emit on terminal 401 expiry (not aborted requests), reset/read refresh on successful pairing. Do not create duplicate auth state. Poll classification and pairing errors distinguish HTTP auth refusal, transport/network, and invalid responses; aborted work does not show a failure. + +P revalidation at81f0c78d7a: same App, Dashboard, pairing and API owners remain. This independent branch starts from refresheddev; previous usage-transportD directs pairing after the user-requested source repairs. + +Concrete delta: App subscribes to SESSION_UNAVAILABLE_EVENT for the shared plane and derives readiness from hasApiSession; ignore a late notice while a newer session is present. Pairing success increments a dashboard refresh epoch and marks ready. Pass connected/authenticationPending into Dashboard. Hide protected dashboard content while authentication is pending; keep known data with a stale notice only on non-auth read failures. In fetchDashboardOverview distinguish 401(auth), 403(denied), other non-OK(request), invalid JSON/shape(invalid), and transport failure(unavailable); aborted polls propagate without publishing an error. Hook exposes failure and overview refresh without a second authentication store. Only standalone transport unavailability may show ocx start; connected/auth/invalid/request failures use relevant copy and retry. + +API wrapper emits its existing unavailable event on terminal401 only when the caller is not aborted and no newer session exists. Retain credential refresh/singleflight behavior; no new auth bypass or token persistence. Dashboard receives success epoch as a prop; useDashboardData adds it to existing useKeyedClientResource revalidation dependencies without changing resource keys or remounting. Every dashboard resource refreshes even when a settled failed/cached store survived. + +Pairing form shows target.serverOrigin, a copyable ocx gui pair --origin command for window.location.origin, instructions to run it on the hub or ask its operator, and the distinction between one-time code and API/admin keys. Reuse useCopyFeedback and existing copy labels; copy failure remains visible. Keep relay technical copy subordinate. Pairing transport gets a typed error kind (invalid-code/refused/unreachable/invalid-response), mapped to localized actionable copy while preserving pasted code; abort does not publish an error. This is process-local UI state, not a wire schema. + +Exact regressions: extend gui/tests/connect-pairing.test.ts for real App dashboard pending/authsuccess/expiry/recovery and hub/command identity; extend api-auth-deadline.test.ts for terminal notice behavior if needed; NEW gui/tests/dashboard-connection-state.test.ts for poll failure classes, cached data and no erroneous start advice. All9 locale modules get new copy. Existing Notice/buttons/tokens, variance2/motion1, dense utility layout; no decorative assets or new dependencies. Hosted built preview, inspected screenshot and browser interaction supply rendered proof later; local suites/build NOT RUN. + +Pairing lifetime precision: form keyed by target server/bootstrap identity, one AbortController per submit cancelled on unmount; transport accepts optional caller signal in addition to its existing fetch seam and checks abort before session installation. This prevents an obsolete target response from installing a session or publishing errors after its form unmounts. Keep existing request method/credential mechanics unchanged. + +A amendment: post-pairing refresh explicitly reaches each dashboard keyed resource through [apiBase, refreshEpoch] dependencies; a component remount is not treated as a cache invalidation mechanism. Regression first seeds a failed overview store, completes pairing, and requires a new authenticated health/provider read plus rendered data. + +Reflection03/05 closure:403 keeps distinct permission-denied guidance and never starts or re-pairs a running proxy merely for denied permissions. Validate HealthData status/version strings and finite nonnegative uptime; providers must be an array of objects with the required name/adapter/baseUrl strings and hasApiKey boolean, optional defaultModel string. Invalid shapes are classified invalid even with HTTP200. Unauthorized/denied content stays hidden; only nonauth read failure may show cached data with stale notice. diff --git a/devlog/_plan/260912_operations/060_transport.md b/devlog/_plan/260912_operations/060_transport.md new file mode 100644 index 0000000000..a884509d22 --- /dev/null +++ b/devlog/_plan/260912_operations/060_transport.md @@ -0,0 +1,7 @@ +# Local management catalog read + +Class C4; dependency roadmap. Scope #4315 and the current CHANGES_REQUESTED review on #4317. Public source starting points: src/cli/opencode.ts fetchOpencodeProxyModels/cmdOpencode, src/lib/admin-secrets.ts, src/lib/local-destinations.ts, associated providers/opencode-cli tests. Reuse existing transport owner after caller search; avoid an opencode-only ad hoc credential client. + +The executable security design and negative-case audit live only in ignored .tmp/operations/060_transport_private.md. That file must be completed and independently reviewed before B; no pre-disclosure reasoning is copied into public planning history. Public deliverable is the implementation, regression tests and shipped contract text only. Required review dimensions: local destination selection, redirect and proxy-environment behavior, credential separation and all current callers. Original contributor credit: Cortes Ventures . No fallback that substitutes a data credential for admin authentication. + +Hosted regression execution plus independent security source audit bind the final patch SHA. Review state is refreshed before handoff; this work cannot approve or merge the original PR. Local suites/build/typecheck/install NOT RUN. diff --git a/devlog/_plan/260912_operations/070_verification.md b/devlog/_plan/260912_operations/070_verification.md new file mode 100644 index 0000000000..e5af9101e3 --- /dev/null +++ b/devlog/_plan/260912_operations/070_verification.md @@ -0,0 +1,9 @@ +# Final tips and handoff + +Dependency: each implementation. No new behavior by default; append a separate PABCD repair cycle when actual final-tip CI failure identifies a necessary delta. + +For each independently mergeable branch: record git rev-parse HEAD, original source PR disposition, included commits, gh pr view headRefOid/baseRefName, successful native-membership read (or unknown), and gh run view for the exact Cross-platform CI run. Manual chains only when later work consumes earlier code; verify lower SHA ancestry at the final tip and record bottom-to-top order. Do not cancel auto-CI or change workflow/protection. No merge/auto-merge, closure, release or user service operation. + +A local receipt may run git diff --check and read-only hosted-result assertions; it is not a local test result. Local suites, typecheck/build/install are NOT RUN. Final behavior acceptance comes from GitHub-hosted test runs at the final SHA and independent review; author reports/old green CI are not substituted. + +Update ignored .tmp/operations/handoff.md as soon as each artifact exists. Include outstanding issue acceptance, original author trailers, unresolved maintainer objections, exact run links/conclusions and cycle ledger pointers. Publish template-complete PR bodies with truthful verification, screenshots for changed dashboard UI and no private investigation notes. Parent owns all integration decisions. diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 0db5e7bcd5..d7c257e41d 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -722,3 +722,9 @@ For a service rollback, stop the branch service and repair the prior release aga session, not a client data key. - **Outstanding revocation after disconnect:** use the hub dashboard's **Integrations → API Keys** page. It is the sole post-disconnect revocation path. + +### Pairing this browser with a hub + +Machine enrollment and browser authentication are separate. The pairing panel names the hub and displays an `ocx gui pair --origin` command for the exact origin currently open in your browser. Run that command on the hub, or send it to the hub operator and request a one-time pairing code. Paste that code into the panel; a data API key or admin token is not a pairing code. + +While browser authentication is pending, the dashboard does not recommend restarting a healthy connected client. Completing pairing refreshes the dashboard data immediately, including a previously cached authentication failure. Session expiry returns to pairing; permission denial keeps its own access-settings guidance. Other failed refreshes may show the last received data with a stale-data notice and retry action. diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 6550f404fe..67361c52fa 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -338,3 +338,9 @@ Adding **Ollama Cloud** or another catalog provider from the dashboard copies it classification into the saved provider config, so the [vision sidecar](/guides/sidecars/) is gated correctly without manual classification. ::: + +### Pairing this browser with a hub + +Machine enrollment and browser authentication are separate. The pairing panel names the hub and displays an `ocx gui pair --origin` command for the exact origin currently open in your browser. Run that command on the hub, or send it to the hub operator and request a one-time pairing code. Paste that code into the panel; a data API key or admin token is not a pairing code. + +While browser authentication is pending, the dashboard does not recommend restarting a healthy connected client. Completing pairing refreshes the dashboard data immediately, including a previously cached authentication failure. Session expiry returns to pairing; permission denial keeps its own access-settings guidance. Other failed refreshes may show the last received data with a stale-data notice and retry action. diff --git a/docs-site/src/content/docs/ko/guides/remote-hub.md b/docs-site/src/content/docs/ko/guides/remote-hub.md index c46bb91b18..a48b53df2c 100644 --- a/docs-site/src/content/docs/ko/guides/remote-hub.md +++ b/docs-site/src/content/docs/ko/guides/remote-hub.md @@ -336,3 +336,9 @@ docker compose up -d - `/v1/catalog`가 `403 origin_rejected`인데 `/readyz`가 `200`이면 데이터 리스너가 TLS 프런트엔드 뒤에서 루프백에 바인드되어 있습니다. [데이터 리스너에 TLS 붙이기](#데이터-리스너에-tls-붙이기)를 보세요. - 브라우저 로그아웃/만료는 해당 원격 세션만 끊습니다. 데이터 키와는 별개입니다. - 연결 해제 후 남은 키는 허브의 **Integrations → API Keys**에서만 폐기할 수 있습니다. + +### 이 브라우저를 허브에 인증하기 + +기기 연결과 브라우저 인증은 별개입니다. 페어링 패널에 표시된 허브에서 현재 브라우저 주소용 `ocx gui pair --origin` 명령을 실행하세요. 직접 운영하지 않는 허브라면 운영자에게 명령을 전달하고 일회용 코드를 요청하세요. 입력 칸에는 페어링 코드를 붙여 넣습니다. 데이터 API 키나 관리자 토큰을 대신 입력하지 마세요. + +인증을 기다리는 동안 정상인 클라이언트를 재시작하라고 안내하지 않습니다. 페어링을 마치면 이전 인증 오류가 캐시에 남아 있어도 대시보드를 새로 읽습니다. 세션이 만료되면 페어링 화면으로 돌아가며, 권한 거부는 별도로 안내합니다. 다른 갱신 오류에서는 마지막 데이터를 오래된 정보로 표시하고 재시도할 수 있습니다. diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 4ae2762b1a..f07c387385 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -229,3 +229,9 @@ GUI에서 OAuth 계정을 선택하면 풀 모드에서도 다음 요청에 반 이유만으로 바꾸지 않아요. 선택 계정이 429를 반환하면 풀이 꺼져 있어도 사용 가능한 다른 계정으로 자동 전환해요. 자동 선택이 저장되면 GUI의 활성 표시도 즉시 바뀌어요. 이미 서버로 보낸 요청의 인증 정보는 바꾸지 않아요. + +### 이 브라우저를 허브에 인증하기 + +기기 연결과 브라우저 인증은 별개입니다. 페어링 패널에 표시된 허브에서 현재 브라우저 주소용 `ocx gui pair --origin` 명령을 실행하세요. 직접 운영하지 않는 허브라면 운영자에게 명령을 전달하고 일회용 코드를 요청하세요. 입력 칸에는 페어링 코드를 붙여 넣습니다. 데이터 API 키나 관리자 토큰을 대신 입력하지 마세요. + +인증을 기다리는 동안 정상인 클라이언트를 재시작하라고 안내하지 않습니다. 페어링을 마치면 이전 인증 오류가 캐시에 남아 있어도 대시보드를 새로 읽습니다. 세션이 만료되면 페어링 화면으로 돌아가며, 권한 거부는 별도로 안내합니다. 다른 갱신 오류에서는 마지막 데이터를 오래된 정보로 표시하고 재시도할 수 있습니다. diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 91890ce664..6b2e1d4c32 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -15,7 +15,7 @@ import { SidebarGithubRow } from "./components/sidebar-github-row"; import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconCodex, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconX, IconRefresh} from "./icons"; import { useI18n, useT, LOCALES, localeDisplayName, type Locale, type TKey } from "./i18n/shared"; import { Select } from "./ui"; -import { configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml, logoutApiSession } from "./api"; +import { configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml, logoutApiSession, SESSION_UNAVAILABLE_EVENT } from "./api"; import { apiBaseForPlane, discoverApiTargets, isConnectedRuntime, standaloneApiTargets, type ApiTargets } from "./api-targets"; import { ConnectPairingForm } from "./connect-pairing"; import { type Page } from "./app-routing"; @@ -111,8 +111,19 @@ export default function App() { const [targetsSettled, setTargetsSettled] = useState(() => !isConnectedRuntime()); const [targetError, setTargetError] = useState(false); const [sharedSessionReady, setSharedSessionReady] = useState(() => hasApiSession("shared")); + const [sharedSessionEpoch, setSharedSessionEpoch] = useState(0); const [sessionLoggingOut, setSessionLoggingOut] = useState(false); + useEffect(() => { + const unavailable = (event: Event) => { + if ((event as CustomEvent<{ plane?: string }>).detail?.plane === "shared" && !hasApiSession("shared")) { + setSharedSessionReady(false); + } + }; + window.addEventListener(SESSION_UNAVAILABLE_EVENT, unavailable); + return () => window.removeEventListener(SESSION_UNAVAILABLE_EVENT, unavailable); + }, []); + useEffect(() => { const controller = new AbortController(); void discoverApiTargets(API_BASE, controller.signal).then(async next => { @@ -422,9 +433,13 @@ export default function App() {
{t("connection.machineUnavailable")}
)} {targets.connected && !sharedSessionReady && ( - setSharedSessionReady(true)} /> + { + setSharedSessionReady(true); + setSharedSessionEpoch(epoch => epoch + 1); + }} /> )} - {page === "dashboard" && } + {page === "dashboard" && } {page === "startup" && } {page === "providers" && } {page === "models" && } diff --git a/gui/src/api.ts b/gui/src/api.ts index 020183dd47..1d51174f9b 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -3,9 +3,8 @@ import { createBoundedFetch } from "./bounded-fetch"; import { adminTokenPromptAllowed, standaloneApiTargets, type ApiPlane, type ApiTarget, type ApiTargets } from "./api-targets"; /** - * Fired instead of the admin-token prompt when the dashboard cannot start a session on a - * deployment that has no admin token to type. The shell renders it as a notice; nothing - * blocks on it. + * Fired after an unauthorized request cannot recover a session. The shell synchronizes + * its existing readiness state; cancelled callers and newer valid sessions emit no notice. */ export const SESSION_UNAVAILABLE_EVENT = "opencodex:session-unavailable"; @@ -282,7 +281,6 @@ async function resolveTokenAfter401(plane: ApiPlane, failedToken: string | null, // of a password box the user cannot answer (#3353, #3483). if (!adminTokenPromptAllowed()) { state.promptCancelled = true; - reportSessionUnavailable(plane); return null; } const prompted = await requestAdminToken(token => verifyAdminToken(plane, token)); @@ -332,10 +330,16 @@ export function installApiAuthFetch(): void { } else clearSessionIfCurrent(classified.plane, token); const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined); const nextToken = await resolveTokenAfter401(classified.plane, token, callerSignal ?? undefined); - if (!nextToken) return response; + if (!nextToken) { + if (!callerSignal?.aborted && !hasApiSession(classified.plane)) reportSessionUnavailable(classified.plane); + return response; + } const [retryInput, retryInit] = withAuth(classified.plane, input, init, nextToken); const retry = await originalFetch(retryInput, retryInit); - if (retry.status === 401) clearSessionIfCurrent(classified.plane, nextToken); + if (retry.status === 401) { + clearSessionIfCurrent(classified.plane, nextToken); + if (!callerSignal?.aborted && !hasApiSession(classified.plane)) reportSessionUnavailable(classified.plane); + } return retry; }; } diff --git a/gui/src/connect-pairing-transport.ts b/gui/src/connect-pairing-transport.ts index fc82035085..cba4958538 100644 --- a/gui/src/connect-pairing-transport.ts +++ b/gui/src/connect-pairing-transport.ts @@ -3,6 +3,13 @@ import type { ApiTarget } from "./api-targets"; const PAIRING_CODE = /^ocx_pair_[A-Za-z0-9_-]{43}$/; +export class PairingError extends Error { + constructor(readonly kind: "invalid-code" | "refused" | "unreachable" | "request-failed" | "invalid-response") { + super(`pairing_${kind}`); + this.name = "PairingError"; + } +} + /** * Exchange a pairing code for a shared-plane session. * @@ -15,9 +22,11 @@ export async function submitConnectPairing( target: ApiTarget, grant: string, fetchImpl?: typeof fetch, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); const code = grant.trim(); - if (!PAIRING_CODE.test(code)) throw new Error("pairing_code_invalid"); + if (!PAIRING_CODE.test(code)) throw new PairingError("invalid-code"); // Resolved at CALL time, not as a default parameter. // // `installApiAuthFetch` replaces `window.fetch` with the wrapper that attaches plane @@ -26,13 +35,25 @@ export async function submitConnectPairing( // evaluated, which on the relay path is the unwrapped original, so the request went out // unauthenticated and the relay refused it. const send = fetchImpl ?? ((input, init) => window.fetch(input, init)); - const response = await send(target.bootstrapPath, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "text/html" }, - body: JSON.stringify({ grant: code }), - }); - if (!response.ok) throw new Error("pairing_refused"); - const html = await response.text(); - if (!installApiSessionFromHtml("shared", html)) throw new Error("pairing_response_invalid"); + let response: Response; + try { + response = await send(target.bootstrapPath, { + method: "POST", signal, + headers: { "Content-Type": "application/json", Accept: "text/html" }, + body: JSON.stringify({ grant: code }), + }); + } catch (error) { + if (signal?.aborted) throw error; + throw new PairingError("unreachable"); + } + if (!response.ok) { + try { await response.body?.cancel(); } catch { /* best effort */ } + throw new PairingError(response.status === 401 || response.status === 403 ? "refused" : "request-failed"); + } + let html: string; + try { html = await response.text(); } + catch (error) { if (signal?.aborted) throw error; throw new PairingError("invalid-response"); } + signal?.throwIfAborted(); + if (!installApiSessionFromHtml("shared", html)) throw new PairingError("invalid-response"); return true; } diff --git a/gui/src/connect-pairing.ts b/gui/src/connect-pairing.ts index 00e48abd7a..5d63b51af9 100644 --- a/gui/src/connect-pairing.ts +++ b/gui/src/connect-pairing.ts @@ -1,7 +1,8 @@ -import { createElement, useState, type ChangeEvent, type FormEvent } from "react"; +import { createElement, useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react"; import type { ApiTarget } from "./api-targets"; import { useT } from "./i18n/shared"; -import { submitConnectPairing } from "./connect-pairing-transport"; +import { PairingError, submitConnectPairing } from "./connect-pairing-transport"; +import { useCopyFeedback } from "./components/use-copy-feedback"; export function ConnectPairingForm({ target, @@ -13,26 +14,41 @@ export function ConnectPairingForm({ const t = useT(); const [grant, setGrant] = useState(""); const [busy, setBusy] = useState(false); - const [error, setError] = useState(false); + const [error, setError] = useState(null); + const activeRequest = useRef(null); + useEffect(() => () => activeRequest.current?.abort(), []); + const copyFeedback = useCopyFeedback(); + const command = `ocx gui pair --origin "${window.location.origin}"`; + const copied = copyFeedback.outcomeFor(command); const submit = async (event: FormEvent) => { event.preventDefault(); if (busy) return; setBusy(true); - setError(false); + setError(null); + const controller = new AbortController(); + activeRequest.current = controller; try { - await submitConnectPairing(target, grant); - onConnected(); - } catch { - setError(true); + await submitConnectPairing(target, grant, undefined, controller.signal); + if (!controller.signal.aborted) onConnected(); + } catch (failure) { + if (!controller.signal.aborted) setError(failure instanceof PairingError ? failure.kind : "unreachable"); } finally { - setBusy(false); + if (!controller.signal.aborted) setBusy(false); + if (activeRequest.current === controller) activeRequest.current = null; } }; return createElement("section", { className: "card connect-pairing", "aria-labelledby": "connect-pairing-title" }, createElement("h2", { id: "connect-pairing-title" }, t("connection.pairing.title")), - createElement("p", null, t(target.transport === "relay" ? "connection.pairing.relayWarning" : "connection.pairing.body")), + createElement("p", null, t("connection.pairing.hub"), ": ", createElement("code", null, target.serverOrigin)), + createElement("p", null, t("connection.pairing.getCode")), + createElement("pre", { style: { whiteSpace: "pre-wrap", overflowWrap: "anywhere" } }, createElement("code", null, command)), + createElement("button", { type: "button", className: "btn btn-ghost", onClick: () => copyFeedback.copy(command, command) }, + t(copied === "copied" ? "startup.copied" : "startup.copy")), + copied === "unavailable" ? createElement("p", { role: "status" }, t("prov.linkCopyUnavailable")) : null, + createElement("p", null, t("connection.pairing.askOperator")), + createElement("p", null, t("connection.pairing.notApiKey")), createElement("form", { onSubmit: submit, className: "api-form-row" }, createElement("label", { htmlFor: "connect-pairing-code", className: "field-label" }, t("connection.pairing.code")), createElement("input", { @@ -44,12 +60,17 @@ export function ConnectPairingForm({ spellCheck: false, disabled: busy, className: "input mono", - "aria-invalid": error || undefined, + "aria-invalid": Boolean(error) || undefined, "aria-describedby": error ? "connect-pairing-error" : undefined, }), createElement("button", { type: "submit", className: "btn btn-primary", disabled: busy || !grant.trim() }, t(busy ? "connection.pairing.submitting" : "connection.pairing.submit")), - error ? createElement("p", { id: "connect-pairing-error", className: "alert alert-err", role: "alert" }, t("connection.pairing.error")) : null, + error ? createElement("p", { id: "connect-pairing-error", className: "alert alert-err", role: "alert" }, + t(error === "invalid-code" ? "connection.pairing.notApiKey" + : error === "unreachable" ? "connection.pairing.networkError" + : error === "request-failed" ? "connection.pairing.requestError" + : error === "invalid-response" ? "connection.pairing.responseError" : "connection.pairing.error")) : null, ), + target.transport === "relay" ? createElement("p", { className: "text-muted" }, t("connection.pairing.relayWarning")) : null, ); } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index fdb18eb6ed..9d19757627 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2569,6 +2569,17 @@ export const de: Record = { "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", "connection.disconnect": "Disconnect from hub", "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.hub": "Hub", + "connection.pairing.getCode": "Führen Sie diesen Befehl für diesen Browser auf dem Hub aus:", + "connection.pairing.askOperator": "Falls jemand anderes den Hub betreibt, senden Sie dieser Person den Befehl und bitten Sie um einen einmaligen Kopplungscode.", + "connection.pairing.notApiKey": "Fügen Sie hier einen einmaligen Kopplungscode ein. Daten-API-Schlüssel und Admin-Token gehören nicht in dieses Feld.", + "connection.pairing.networkError": "Der Hub ist nicht erreichbar. Prüfen Sie die Verbindung und versuchen Sie es erneut; Ihr Code bleibt erhalten.", + "connection.pairing.requestError": "Der Hub konnte die Kopplungsanfrage nicht abschließen. Prüfen Sie seinen Status und versuchen Sie es erneut.", + "connection.pairing.responseError": "Der Hub hat keine gültige Browsersitzung zurückgegeben. Aktualisieren Sie den Hub oder fragen Sie den Betreiber und versuchen Sie es erneut.", + "dash.authRequired": "Zum Anzeigen dieses Dashboards ist eine Browserauthentifizierung erforderlich.", + "dash.permissionDenied": "Dieser Browser darf das Dashboard nicht lesen. Prüfen Sie die Zugriffsrechte mit dem Serverbetreiber.", + "dash.dataUnavailable": "Dashboard-Daten konnten nicht geladen werden. Prüfen Sie die Verbindung und versuchen Sie es erneut.", + "dash.staleData": "Die zuletzt empfangenen Daten werden angezeigt; sie können veraltet sein.", "connection.pairing.title": "Connect this dashboard to the hub", "connection.pairing.body": "Paste the one-time pairing code created on the hub.", "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 1847a7af7e..587c79addc 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2644,6 +2644,17 @@ export const en = { "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", "connection.disconnect": "Disconnect from hub", "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.hub": "Hub", + "connection.pairing.getCode": "Run this command on the hub for this browser:", + "connection.pairing.askOperator": "If someone else operates the hub, send them this command and ask for a one-time pairing code.", + "connection.pairing.notApiKey": "Paste a one-time pairing code here. Data API keys and admin tokens do not belong in this field.", + "connection.pairing.networkError": "Could not reach the hub. Check the connection and retry; your code is still here.", + "connection.pairing.requestError": "The hub could not complete the pairing request. Check its status and retry.", + "connection.pairing.responseError": "The hub did not return a valid browser session. Update the hub or ask its operator, then retry.", + "dash.authRequired": "Browser authentication is required to view this dashboard.", + "dash.permissionDenied": "This browser is not permitted to read the dashboard. Check access settings with the server operator.", + "dash.dataUnavailable": "Dashboard data could not be loaded. Check the connection and retry.", + "dash.staleData": "Showing the last received data; it may be out of date.", "connection.pairing.title": "Connect this dashboard to the hub", "connection.pairing.body": "Paste the one-time pairing code created on the hub.", "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index e465adbb10..b4468ccf1e 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2557,6 +2557,17 @@ export const fr: Record = { "connection.machineUnavailable": "Le plan machine local est indisponible. Les requêtes partagées n'ont pas été redirigées localement.", "connection.disconnect": "Déconnecter du hub", "connection.disconnectConfirm": "Déconnecter cette machine du hub et la redémarrer en mode autonome ?", + "connection.pairing.hub": "Hub", + "connection.pairing.getCode": "Exécutez cette commande sur le hub pour ce navigateur :", + "connection.pairing.askOperator": "Si une autre personne gère le hub, transmettez-lui cette commande et demandez un code de jumelage à usage unique.", + "connection.pairing.notApiKey": "Collez ici un code de jumelage à usage unique. Ce champ ne reçoit ni clé API de données ni jeton administrateur.", + "connection.pairing.networkError": "Impossible de joindre le hub. Vérifiez la connexion et réessayez ; votre code est conservé.", + "connection.pairing.requestError": "Le hub n’a pas pu terminer la demande de jumelage. Vérifiez son état et réessayez.", + "connection.pairing.responseError": "Le hub n’a pas renvoyé de session de navigateur valide. Mettez-le à jour ou contactez son responsable, puis réessayez.", + "dash.authRequired": "Une authentification du navigateur est nécessaire pour consulter ce tableau de bord.", + "dash.permissionDenied": "Ce navigateur n’a pas accès au tableau de bord. Vérifiez les autorisations avec le responsable du serveur.", + "dash.dataUnavailable": "Impossible de charger les données du tableau de bord. Vérifiez la connexion et réessayez.", + "dash.staleData": "Les dernières données reçues sont affichées ; elles peuvent être obsolètes.", "connection.pairing.title": "Connecter ce tableau de bord au hub", "connection.pairing.body": "Collez le code d'association à usage unique créé sur le hub.", "connection.pairing.relayWarning": "Ce code passe par le relais fixe du hub. Le relais ne peut pas viser un autre hôte.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e9a3d9f58b..da0d8cf81a 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2590,6 +2590,17 @@ export const ja: Record = { "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", "connection.disconnect": "Disconnect from hub", "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.hub": "ハブ", + "connection.pairing.getCode": "このブラウザーを認証するには、ハブで次のコマンドを実行してください。", + "connection.pairing.askOperator": "ハブの管理者が別にいる場合は、このコマンドを渡して使い捨てのペアリングコードを依頼してください。", + "connection.pairing.notApiKey": "ここには使い捨てのペアリングコードを貼り付けてください。データAPIキーや管理者トークンは入力しないでください。", + "connection.pairing.networkError": "ハブに接続できません。接続を確認して再試行してください。入力したコードは保持されています。", + "connection.pairing.requestError": "ハブがペアリング要求を完了できませんでした。状態を確認して再試行してください。", + "connection.pairing.responseError": "ハブが有効なブラウザーセッションを返しませんでした。ハブを更新するか管理者に確認して再試行してください。", + "dash.authRequired": "このダッシュボードを表示するにはブラウザーの認証が必要です。", + "dash.permissionDenied": "このブラウザーにはダッシュボードの閲覧権限がありません。サーバー管理者にアクセス設定を確認してください。", + "dash.dataUnavailable": "ダッシュボードのデータを読み込めませんでした。接続を確認して再試行してください。", + "dash.staleData": "最後に受信したデータを表示しています。最新の状態とは異なる場合があります。", "connection.pairing.title": "Connect this dashboard to the hub", "connection.pairing.body": "Paste the one-time pairing code created on the hub.", "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 67ee251970..2c4264d781 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2591,6 +2591,17 @@ export const ko: Record = { "connection.machineUnavailable": "로컬 머신 연결을 사용할 수 없습니다. 공유 요청을 로컬로 우회하지 않았습니다.", "connection.disconnect": "허브 연결 해제", "connection.disconnectConfirm": "이 머신의 허브 연결을 해제하고 독립 실행 모드로 다시 시작할까요?", + "connection.pairing.hub": "허브", + "connection.pairing.getCode": "이 브라우저를 인증하려면 허브에서 다음 명령을 실행하세요.", + "connection.pairing.askOperator": "허브를 다른 사람이 운영한다면 이 명령을 전달하고 일회용 페어링 코드를 요청하세요.", + "connection.pairing.notApiKey": "이 칸에는 일회용 페어링 코드를 붙여 넣으세요. 데이터 API 키나 관리자 토큰을 입력하는 칸이 아닙니다.", + "connection.pairing.networkError": "허브에 연결할 수 없습니다. 연결을 확인하고 다시 시도하세요. 입력한 코드는 유지됩니다.", + "connection.pairing.requestError": "허브가 페어링 요청을 완료하지 못했습니다. 허브 상태를 확인하고 다시 시도하세요.", + "connection.pairing.responseError": "허브가 유효한 브라우저 세션을 반환하지 않았습니다. 허브를 업데이트하거나 운영자에게 확인한 뒤 다시 시도하세요.", + "dash.authRequired": "이 대시보드를 보려면 브라우저 인증이 필요합니다.", + "dash.permissionDenied": "이 브라우저에는 대시보드 조회 권한이 없습니다. 서버 운영자에게 접근 설정을 확인하세요.", + "dash.dataUnavailable": "대시보드 데이터를 불러오지 못했습니다. 연결을 확인하고 다시 시도하세요.", + "dash.staleData": "마지막으로 받은 데이터를 표시합니다. 최신 상태와 다를 수 있습니다.", "connection.pairing.title": "이 대시보드를 허브에 연결", "connection.pairing.body": "허브에서 만든 일회용 페어링 코드를 붙여 넣으세요.", "connection.pairing.relayWarning": "이 코드는 고정 허브 릴레이로 교환됩니다. 릴레이 목적지는 다른 호스트로 바꿀 수 없습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 56d43fc301..35d07b7a9d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2592,6 +2592,17 @@ export const ru: Record = { "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", "connection.disconnect": "Disconnect from hub", "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.hub": "Хаб", + "connection.pairing.getCode": "Выполните эту команду на хабе для этого браузера:", + "connection.pairing.askOperator": "Если хабом управляет другой человек, передайте ему эту команду и попросите одноразовый код сопряжения.", + "connection.pairing.notApiKey": "Вставьте сюда одноразовый код сопряжения. Ключи API данных и токены администратора в это поле не вводятся.", + "connection.pairing.networkError": "Не удалось связаться с хабом. Проверьте соединение и повторите попытку; введённый код сохранён.", + "connection.pairing.requestError": "Хаб не смог завершить запрос сопряжения. Проверьте его состояние и повторите попытку.", + "connection.pairing.responseError": "Хаб не вернул действительный сеанс браузера. Обновите хаб или обратитесь к его оператору и повторите попытку.", + "dash.authRequired": "Для просмотра этой панели требуется аутентификация браузера.", + "dash.permissionDenied": "У этого браузера нет доступа к панели. Уточните настройки доступа у оператора сервера.", + "dash.dataUnavailable": "Не удалось загрузить данные панели. Проверьте соединение и повторите попытку.", + "dash.staleData": "Показаны последние полученные данные; они могут быть устаревшими.", "connection.pairing.title": "Connect this dashboard to the hub", "connection.pairing.body": "Paste the one-time pairing code created on the hub.", "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b627813bb9..cbc994e32b 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2592,6 +2592,17 @@ export const tr: Record = { "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", "connection.disconnect": "Disconnect from hub", "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.hub": "Merkez", + "connection.pairing.getCode": "Bu tarayıcı için merkezde şu komutu çalıştırın:", + "connection.pairing.askOperator": "Merkezi başka biri yönetiyorsa bu komutu ona gönderip tek kullanımlık eşleştirme kodu isteyin.", + "connection.pairing.notApiKey": "Buraya tek kullanımlık eşleştirme kodunu yapıştırın. Veri API anahtarları ve yönetici belirteçleri bu alana girilmez.", + "connection.pairing.networkError": "Merkeze ulaşılamadı. Bağlantıyı kontrol edip yeniden deneyin; kodunuz korunuyor.", + "connection.pairing.requestError": "Merkez eşleştirme isteğini tamamlayamadı. Durumunu kontrol edip yeniden deneyin.", + "connection.pairing.responseError": "Merkez geçerli bir tarayıcı oturumu döndürmedi. Merkezi güncelleyin veya yöneticisine danışıp yeniden deneyin.", + "dash.authRequired": "Bu panoyu görüntülemek için tarayıcı kimlik doğrulaması gerekiyor.", + "dash.permissionDenied": "Bu tarayıcının panoyu okuma izni yok. Erişim ayarlarını sunucu yöneticisiyle kontrol edin.", + "dash.dataUnavailable": "Pano verileri yüklenemedi. Bağlantıyı kontrol edip yeniden deneyin.", + "dash.staleData": "Son alınan veriler gösteriliyor; güncel olmayabilir.", "connection.pairing.title": "Connect this dashboard to the hub", "connection.pairing.body": "Paste the one-time pairing code created on the hub.", "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ce06556fd4..ca523fcca0 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2555,6 +2555,17 @@ export const zhTW: Record = { "connection.machineUnavailable": "本機機器平面無法使用。共享請求未改用本機資料。", "connection.disconnect": "中斷 Hub 連線", "connection.disconnectConfirm": "要中斷此機器與 Hub 的連線,並以獨立模式重新啟動嗎?", + "connection.pairing.hub": "中樞", + "connection.pairing.getCode": "請在中樞上為此瀏覽器執行以下命令:", + "connection.pairing.askOperator": "如果中樞由他人管理,請將此命令交給管理員並索取一次性配對碼。", + "connection.pairing.notApiKey": "請在此貼上一次性配對碼。此欄位不接受資料 API 金鑰或管理員權杖。", + "connection.pairing.networkError": "無法連線至中樞。請檢查連線後重試;已輸入的配對碼會保留。", + "connection.pairing.requestError": "中樞無法完成配對請求。請檢查其狀態後重試。", + "connection.pairing.responseError": "中樞未傳回有效的瀏覽器工作階段。請更新中樞或聯絡管理員後重試。", + "dash.authRequired": "檢視此儀表板需要瀏覽器身分驗證。", + "dash.permissionDenied": "此瀏覽器無權讀取儀表板。請聯絡伺服器管理員檢查存取設定。", + "dash.dataUnavailable": "無法載入儀表板資料。請檢查連線後重試。", + "dash.staleData": "正在顯示最後收到的資料,可能已過時。", "connection.pairing.title": "將此儀表板連接到 Hub", "connection.pairing.body": "貼上在 Hub 建立的一次性配對碼。", "connection.pairing.relayWarning": "此代碼透過固定 Hub 轉送交換,無法重新導向其他主機。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ae2fdfec92..a58ae6dd45 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2590,6 +2590,17 @@ export const zh: Record = { "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", "connection.disconnect": "Disconnect from hub", "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.hub": "中心", + "connection.pairing.getCode": "请在中心上为此浏览器运行以下命令:", + "connection.pairing.askOperator": "如果中心由他人管理,请将此命令发给管理员并索取一次性配对码。", + "connection.pairing.notApiKey": "请在此粘贴一次性配对码。此字段不接受数据 API 密钥或管理员令牌。", + "connection.pairing.networkError": "无法连接中心。请检查连接后重试;已输入的配对码会保留。", + "connection.pairing.requestError": "中心无法完成配对请求。请检查其状态后重试。", + "connection.pairing.responseError": "中心未返回有效的浏览器会话。请更新中心或联系管理员后重试。", + "dash.authRequired": "查看此仪表板需要浏览器身份验证。", + "dash.permissionDenied": "此浏览器无权读取仪表板。请联系服务器管理员检查访问设置。", + "dash.dataUnavailable": "无法加载仪表板数据。请检查连接后重试。", + "dash.staleData": "正在显示最后收到的数据,可能已过时。", "connection.pairing.title": "Connect this dashboard to the hub", "connection.pairing.body": "Paste the one-time pairing code created on the hub.", "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", diff --git a/gui/src/pages/Dashboard.tsx b/gui/src/pages/Dashboard.tsx index e32a671ec8..16b221834f 100644 --- a/gui/src/pages/Dashboard.tsx +++ b/gui/src/pages/Dashboard.tsx @@ -2,7 +2,7 @@ import { type ReactNode } from "react"; import { IconAlert } from "../icons"; import { Trans } from "../i18n/provider"; import { navigateHash } from "../hash-routing"; -import { EmptyState } from "../ui"; +import { EmptyState, Notice } from "../ui"; import { DashboardDialogs } from "./dashboard-dialogs"; import { DashboardModelsSection } from "./dashboard-models-section"; import { DashboardOverviewSection } from "./dashboard-overview-section"; @@ -18,19 +18,25 @@ function selectDashboardTab(next: DashboardSection) { navigateHash(dashboardHashForSection(next)); } -export default function Dashboard({ apiBase }: { apiBase: string }) { - const d = useDashboardData(apiBase); +export default function Dashboard({ apiBase, connected = false, authenticationPending = false, refreshEpoch = 0 }: { + apiBase: string; connected?: boolean; authenticationPending?: boolean; refreshEpoch?: number; +}) { + const d = useDashboardData(apiBase, refreshEpoch); const { t, error, selectedSection, providers, models, modelsLoading, modelQuery, setModelQuery, filteredGroups, expandedProviders, setExpandedProviders, } = d; - if (error) { + if (authenticationPending) return null; + const accessFailure = d.connectionFailure === "auth" || d.connectionFailure === "denied"; + if (error && (accessFailure || !d.health)) { return ( } - title={{t("dash.cannotConnect")}}> - + title={{t(d.connectionFailure === "denied" + ? "dash.permissionDenied" : d.connectionFailure === "auth" ? "dash.authRequired" : "dash.dataUnavailable")}}> + {!connected && d.connectionFailure === "unavailable" && } + ); } @@ -74,6 +80,8 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { return (
+ {error && {t("dash.staleData")} }

{t("nav.dashboard")}

diff --git a/gui/src/pages/dashboard-core-poll.ts b/gui/src/pages/dashboard-core-poll.ts index f6bb653452..5769d0080b 100644 --- a/gui/src/pages/dashboard-core-poll.ts +++ b/gui/src/pages/dashboard-core-poll.ts @@ -51,6 +51,7 @@ export type DashboardOverviewPoll = { health: HealthData | null; providers: ProviderInfo[]; error: boolean; + failure?: "auth" | "denied" | "request" | "invalid" | "unavailable"; }; /** Multi-agent extras — slower peers must not gate status/uptime/provider counts. */ @@ -252,16 +253,33 @@ export async function fetchDashboardOverview( apiBase: string, signal: AbortSignal, ): Promise { + const failed = (failure: DashboardOverviewPoll["failure"]): DashboardOverviewPoll => ({ health: null, providers: [], error: true, failure }); + let hRes: Response; + let pRes: Response; try { - const [hRes, pRes] = await Promise.all([ + [hRes, pRes] = await Promise.all([ fetch(`${apiBase}/api/system/health`, { signal }), fetch(`${apiBase}/api/providers`, { signal }), ]); + } catch (error) { + if (isAbortError(error, signal)) throw error; + return failed("unavailable"); + } + if (hRes.status === 403 || pRes.status === 403) return failed("denied"); + if (hRes.status === 401 || pRes.status === 401) return failed("auth"); + if (!hRes.ok || !pRes.ok) return failed("request"); + try { const health = await requireJson(hRes); const providers = await requireJson(pRes); + if (!health || typeof health.status !== "string" || typeof health.version !== "string" + || !Number.isFinite(health.uptime) || health.uptime < 0 + || !Array.isArray(providers) || providers.some(row => !row || typeof row.name !== "string" + || typeof row.adapter !== "string" || typeof row.baseUrl !== "string" || typeof row.hasApiKey !== "boolean" + || (row.defaultModel !== undefined && typeof row.defaultModel !== "string"))) return failed("invalid"); return { health, providers, error: false }; - } catch { - return { health: null, providers: [], error: true }; + } catch (error) { + if (isAbortError(error, signal)) throw error; + return failed("invalid"); } } diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 5bbe1210bc..410e774edc 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -135,7 +135,7 @@ function controlsCacheKey(apiBase: string): string { return `${CONTROLS_CACHE_PREFIX}${apiBase}`; } -export function useDashboardData(apiBase: string) { +export function useDashboardData(apiBase: string, refreshEpoch = 0) { const { locale, t } = useI18n(); // The hash is the source of truth for the active section (#dashboard, …). const [selectedSection, setSelectedSection] = useState(readDashboardSectionFromHash); @@ -253,7 +253,7 @@ export function useDashboardData(apiBase: string) { const startupHealthPoll = useKeyedClientResource( `dashboard-startup-health:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], (signal) => fetchStartupHealth(apiBase, signal), { pollMs: 30_000 }, ); @@ -275,7 +275,7 @@ export function useDashboardData(apiBase: string) { // Wave 1: status/uptime/providers must not wait on injection-model / usage. const overviewPoll = useKeyedClientResource( `dashboard-overview:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], (signal) => fetchDashboardOverview(apiBase, signal), { pollMs: 5000 }, ); @@ -284,14 +284,14 @@ export function useDashboardData(apiBase: string) { // Preferences that are just config — never gate on overview or injection. const maModePoll = useKeyedClientResource( `dashboard-ma-mode:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], (signal) => fetchDashboardMaMode(apiBase, signal), { pollMs: 5000 }, ); const sidecarPoll = useKeyedClientResource( `dashboard-sidecars:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], async (signal) => { const startupHealthGeneration = startupHealthGenerationRef.current; const data = await fetchDashboardSidecars(apiBase, signal, epochRefs); @@ -302,7 +302,7 @@ export function useDashboardData(apiBase: string) { const settingsPoll = useKeyedClientResource( `dashboard-settings:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], async (signal) => { const startupHealthGeneration = startupHealthGenerationRef.current; const data = await fetchDashboardSettings(apiBase, signal, epochRefs); @@ -314,14 +314,14 @@ export function useDashboardData(apiBase: string) { // Wave 2: heavier peers start after overview commits (or session seed) to cut contention. const multiAgentPoll = useKeyedClientResource( `dashboard-multi-agent:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], (signal) => fetchDashboardMultiAgent(apiBase, signal), { pollMs: 5000, enabled: overviewReady }, ); const usagePoll = useKeyedClientResource( usageSummary30dResourceKey(apiBase), - [apiBase], + [apiBase, refreshEpoch], (signal) => fetchDashboardUsage(apiBase, signal), // 30d usage is documented ~5s cold; this shared key has four subscribers, so // every one of them carries the same raised deadline (mount-order independent). @@ -330,14 +330,14 @@ export function useDashboardData(apiBase: string) { const diagnosticsPoll = useKeyedClientResource( `dashboard-diagnostics:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], (signal) => fetchProjectConfigDiagnostics(apiBase, signal), { pollMs: PROJECT_CONFIG_DIAGNOSTICS_POLL_MS, enabled: overviewReady }, ); const modelsPoll = useKeyedClientResource( `dashboard-models:${apiBase}`, - [apiBase, error], + [apiBase, error, refreshEpoch], (signal) => fetchDashboardModels(apiBase, signal), { enabled: overviewReady && !error }, ); @@ -851,6 +851,7 @@ export function useDashboardData(apiBase: string) { syncResult, syncError, projectConfigWarnings, updateOpen, updateChannel, setUpdateRestart, updateRestart, updateLoading, updateCheck, updateError, updateJob, reconnecting, error, + connectionFailure: overviewPoll.data?.failure, refreshDashboard: overviewPoll.refresh, effortCapHelpTriggerRef, updateTriggerRef, maHelpTriggerRef, shadowCallHelpTriggerRef, effortCapHelpDialogRef, updateDialogRef, maHelpDialogRef, shadowCallHelpDialogRef, filteredGroups, sidecarModels, visionModels, diff --git a/gui/tests/connect-pairing.test.ts b/gui/tests/connect-pairing.test.ts index ae68a1d1f7..e65ed3c6be 100644 --- a/gui/tests/connect-pairing.test.ts +++ b/gui/tests/connect-pairing.test.ts @@ -5,7 +5,7 @@ import { act, createElement } from "react"; test("App mounts the relay pairing form and installs only the returned shared session", async () => { const keys = ["window", "document", "navigator", "sessionStorage", "localStorage", "fetch", "confirm", "alert", "IS_REACT_ACT_ENVIRONMENT", "__APP_VERSION__"] as const; const previous = Object.fromEntries(keys.map(key => [key, Reflect.get(globalThis, key)])); - const win = new Window({ url: "http://localhost/#usage" }); + const win = new Window({ url: "http://localhost/#dashboard" }); Object.defineProperties(globalThis, { window: { configurable: true, value: win }, document: { configurable: true, value: win.document }, @@ -33,6 +33,9 @@ test("App mounts the relay pairing form and installs only the returned shared se document.head.append(meta); } + let authorized = false; + let rejectSession = false; + let authenticatedHealthReads = 0; let pairingRequest: { method: string; body: string; headers: Headers } | null = null; const sessionHtml = [ '', @@ -51,9 +54,21 @@ test("App mounts the relay pairing form and installs only the returned shared se hubReachability: "unknown", }); if (url.pathname === "/api/machine/hub-relay/opencodex-session" && init?.method === "POST") { + authorized = true; rejectSession = false; pairingRequest = { method: init.method, body: String(init.body), headers }; return new Response(sessionHtml, { headers: { "Content-Type": "text/html" } }); } + if (url.pathname.endsWith("/opencodex-session")) return new Response(null, { status: 401 }); + if (url.pathname.endsWith("/api/system/health")) { + if (!authorized || rejectSession) return new Response(null, { status: 401 }); + expect(headers.get("x-opencodex-api-key")).toBe("ocx_session_hub"); + authenticatedHealthReads++; + return Response.json({ status: "ok", version: "0.0.0-test", uptime: 30 }); + } + if (url.pathname.endsWith("/api/providers")) return Response.json([ + { name: "fixture", adapter: "openai-chat", baseUrl: "https://fixture.example.test", hasApiKey: false }, + ]); + if (url.pathname.endsWith("/api/models")) return Response.json([]); if (url.pathname === "/healthz") return Response.json({ version: "0.0.0-test" }); if (url.pathname.endsWith("/api/usage")) return Response.json({ range: "30d", surface: "all", since: null, generatedAt: Date.now(), @@ -85,6 +100,11 @@ test("App mounts the relay pairing form and installs only the returned shared se installApiAuthFetch(); const { default: App } = await import("../src/App"); Object.defineProperty(globalThis, "fetch", { configurable: true, value: win.fetch }); + const resources = await import("../src/client-resource"); + resources.clearClientResourceStoresForTests(); + resources.setClientResourceData("dashboard-overview:http://localhost/api/machine/hub-relay", { + health: null, providers: [], error: true, failure: "auth", + }); const { createRoot } = await import("react-dom/client"); const root = createRoot(container); try { @@ -94,6 +114,10 @@ test("App mounts the relay pairing form and installs only the returned shared se if (Date.now() >= deadline) throw new Error("pairing form did not mount from App"); await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 10)); }); } + expect(container.textContent).toContain("https://hub.example.test"); + expect(container.textContent).toContain('ocx gui pair --origin "http://localhost"'); + expect(container.textContent).not.toContain("ocx start"); + expect(container.querySelector(".dashboard-workspace-shell")).toBeNull(); const input = container.querySelector("#connect-pairing-code") as HTMLInputElement; Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input, `ocx_pair_${"a".repeat(43)}`); await act(async () => { input.dispatchEvent(new win.Event("input", { bubbles: true })); }); @@ -108,8 +132,36 @@ test("App mounts the relay pairing form and installs only the returned shared se expect(pairingRequest?.body).toBe(JSON.stringify({ grant: `ocx_pair_${"a".repeat(43)}` })); expect(pairingRequest?.headers.get("x-opencodex-machine-session")).toBe("ocx_session_machine"); expect(pairingRequest?.headers.get("x-opencodex-api-key")).toBeNull(); + const refreshDeadline = Date.now() + 5_000; + while (authenticatedHealthReads === 0 || !container.querySelector(".dashboard-workspace-shell")) { + if (Date.now() >= refreshDeadline) throw new Error("pairing did not refresh the retained failed dashboard store"); + await act(async () => { await new Promise(resolve => setImmediate(resolve)); }); + } + await act(async () => { + resources.setClientResourceData("dashboard-overview:http://localhost/api/machine/hub-relay", { + health: null, providers: [], error: true, failure: "unavailable", + }); + }); + expect(container.querySelector(".dashboard-workspace-shell")).not.toBeNull(); + expect(container.textContent).toContain("Showing the last received data"); + expect(container.textContent).not.toContain("ocx start"); + await act(async () => { + resources.setClientResourceData("dashboard-overview:http://localhost/api/machine/hub-relay", { + health: null, providers: [], error: true, failure: "denied", + }); + }); + expect(container.querySelector(".dashboard-workspace-shell")).toBeNull(); + expect(container.textContent).toContain("not permitted to read the dashboard"); + expect(container.textContent).not.toContain("ocx start"); + rejectSession = true; + await act(async () => { expect((await fetch("http://localhost/api/machine/hub-relay/api/system/health")).status).toBe(401); }); + expect(container.querySelector("#connect-pairing-code")).not.toBeNull(); + expect(container.querySelector(".dashboard-workspace-shell")).toBeNull(); + expect(container.textContent).not.toContain("ocx start"); + } finally { await act(async () => { root.unmount(); }); + resources.clearClientResourceStoresForTests(); container.remove(); win.close(); for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: previous[key] }); @@ -161,3 +213,33 @@ test("a refused pairing renders an accessible error without clearing the pasted for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: previous[key] }); } }); + + +test("a cancelled pairing body cannot install its obsolete session", async () => { + const { submitConnectPairing } = await import("../src/connect-pairing-transport"); + const controller = new AbortController(); + let release!: (text: string) => void; + let reading!: () => void; + const started = new Promise(resolve => { reading = resolve; }); + const response = new Response(""); + response.text = () => new Promise(resolve => { release = resolve; reading(); }); + const pending = submitConnectPairing({ id: "shared", baseUrl: "https://hub.example.test", + serverOrigin: "https://hub.example.test", bootstrapPath: "https://hub.example.test/opencodex-session", transport: "direct" }, + `ocx_pair_${"a".repeat(43)}`, (async () => response) as typeof fetch, controller.signal); + await started; + controller.abort(); + release(''); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); +}); + +test("pairing reports refusal, server failure and network failure separately", async () => { + const { submitConnectPairing } = await import("../src/connect-pairing-transport"); + const target = { id: "shared" as const, baseUrl: "https://hub.example.test", serverOrigin: "https://hub.example.test", + bootstrapPath: "https://hub.example.test/opencodex-session", transport: "direct" as const }; + for (const [status, kind] of [[403, "refused"], [503, "request-failed"]] as const) { + await expect(submitConnectPairing(target, `ocx_pair_${"a".repeat(43)}`, + (async () => new Response(null, { status })) as typeof fetch)).rejects.toMatchObject({ kind }); + } + await expect(submitConnectPairing(target, `ocx_pair_${"a".repeat(43)}`, + (async () => { throw new Error("network"); }) as typeof fetch)).rejects.toMatchObject({ kind: "unreachable" }); +}); diff --git a/gui/tests/dashboard-connection-state.test.ts b/gui/tests/dashboard-connection-state.test.ts new file mode 100644 index 0000000000..7597d9bc04 --- /dev/null +++ b/gui/tests/dashboard-connection-state.test.ts @@ -0,0 +1,34 @@ +import { afterEach, expect, test } from "bun:test"; +import { fetchDashboardOverview } from "../src/pages/dashboard-core-poll"; + +const originalFetch = globalThis.fetch; +const health = { status: "ok", version: "1.0.0", uptime: 10 }; +const providers = [{ name: "fixture", adapter: "openai-chat", baseUrl: "https://fixture.example.test", hasApiKey: false }]; +afterEach(() => { globalThis.fetch = originalFetch; }); + +test.each([[401, "auth"], [403, "denied"], [500, "request"]] as const)("HTTP %i has its own dashboard failure meaning", async (status, failure) => { + globalThis.fetch = (async () => new Response(null, { status })) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toMatchObject({ error: true, failure }); +}); + +test.each([{}, { ...health, uptime: "ten" }, { ...health, uptime: -1 }])("malformed health JSON is not treated as a stopped proxy", async invalid => { + globalThis.fetch = (async input => Response.json(String(input).endsWith("/api/providers") ? providers : invalid)) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toMatchObject({ failure: "invalid" }); +}); + +test.each([{}, [null], [{ name: "fixture" }]])("malformed providers are rejected before caching", async invalid => { + globalThis.fetch = (async input => Response.json(String(input).endsWith("/api/providers") ? invalid : health)) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toMatchObject({ failure: "invalid" }); +}); + +test("valid dashboard response retains the existing success shape", async () => { + globalThis.fetch = (async input => Response.json(String(input).endsWith("/api/providers") ? providers : health)) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toEqual({ health, providers, error: false }); +}); + +test("transport failure is distinct from cancelled polling", async () => { + globalThis.fetch = (async () => { throw new TypeError("network unavailable"); }) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toMatchObject({ failure: "unavailable" }); + const controller = new AbortController(); controller.abort(); + await expect(fetchDashboardOverview("", controller.signal)).rejects.toThrow(); +}); diff --git a/structure/design-methodology.md b/structure/design-methodology.md index 51a2f158bc..5d210c1bbc 100644 --- a/structure/design-methodology.md +++ b/structure/design-methodology.md @@ -39,3 +39,5 @@ surfaces, run through all 3 stages in order. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +The pairing panel names the hub, offers an origin-specific command to run on that hub, and separates one-time codes from data/admin credentials. Copy outcomes and request failures use existing notice/button patterns. Failed authentication never masquerades as a stopped connected process. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index a3c61e1f7d..a32de28100 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -537,3 +537,5 @@ advances the observation clock, so a retained older row cannot defer evaluation Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +The connected browser shell reuses `SESSION_UNAVAILABLE_EVENT` and its shared-session readiness state. Terminal 401 recovery failure exposes pairing without a restart instruction; a newer session or aborted request cannot publish an unavailable notice. Successful pairing changes dashboard resource revalidation dependencies, so retained failed stores are explicitly refreshed. Dashboard reads distinguish authentication, permission denial, request failure, invalid payload and transport failure; protected data is hidden for authentication/denial, while other failed refreshes label retained data as stale. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 78d0e038ec..873fdbaca8 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -312,3 +312,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Hub/browser pairing instructions distinguish machine enrollment, session authentication, permission denial and network failure. The hosted dashboard preview is the render artifact used to review these states. diff --git a/structure/overview.md b/structure/overview.md index da3f2dc473..0ffa16de2d 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -106,3 +106,5 @@ would pass while the rule was violated. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +Connected-browser pairing and dashboard failure meanings follow the [management UI contract](gui-and-management-api.md#dashboard-surfaces); machine enrollment alone does not authenticate a browser. From 4124a644385c26d699d18f175bcb04beba8b2e1d Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 17:06:21 +0900 Subject: [PATCH 2/4] fix(dashboard): retain access errors across compound poll failures --- gui/src/pages/dashboard-core-poll.ts | 63 ++++++++++++-------- gui/src/pages/use-dashboard-data.ts | 6 +- gui/tests/dashboard-connection-state.test.ts | 16 +++++ structure/gui-and-management-api.md | 2 + 4 files changed, 60 insertions(+), 27 deletions(-) diff --git a/gui/src/pages/dashboard-core-poll.ts b/gui/src/pages/dashboard-core-poll.ts index 5769d0080b..1b1e98461e 100644 --- a/gui/src/pages/dashboard-core-poll.ts +++ b/gui/src/pages/dashboard-core-poll.ts @@ -254,32 +254,45 @@ export async function fetchDashboardOverview( signal: AbortSignal, ): Promise { const failed = (failure: DashboardOverviewPoll["failure"]): DashboardOverviewPoll => ({ health: null, providers: [], error: true, failure }); - let hRes: Response; - let pRes: Response; - try { - [hRes, pRes] = await Promise.all([ - fetch(`${apiBase}/api/system/health`, { signal }), - fetch(`${apiBase}/api/providers`, { signal }), - ]); - } catch (error) { - if (isAbortError(error, signal)) throw error; - return failed("unavailable"); - } - if (hRes.status === 403 || pRes.status === 403) return failed("denied"); - if (hRes.status === 401 || pRes.status === 401) return failed("auth"); - if (!hRes.ok || !pRes.ok) return failed("request"); + const controller = new AbortController(); + const requestSignal = AbortSignal.any([signal, controller.signal]); + let accessFailure: "auth" | "denied" | undefined; + let notifyAccess!: () => void; + const accessChanged = new Promise<"access">(resolve => { notifyAccess = () => resolve("access"); }); + const observe = (response: Response) => { + if (response.status === 401 || response.status === 403) { + if (response.status === 403 || accessFailure === undefined) accessFailure = response.status === 403 ? "denied" : "auth"; + notifyAccess(); + } + return response; + }; try { - const health = await requireJson(hRes); - const providers = await requireJson(pRes); - if (!health || typeof health.status !== "string" || typeof health.version !== "string" - || !Number.isFinite(health.uptime) || health.uptime < 0 - || !Array.isArray(providers) || providers.some(row => !row || typeof row.name !== "string" - || typeof row.adapter !== "string" || typeof row.baseUrl !== "string" || typeof row.hasApiKey !== "boolean" - || (row.defaultModel !== undefined && typeof row.defaultModel !== "string"))) return failed("invalid"); - return { health, providers, error: false }; - } catch (error) { - if (isAbortError(error, signal)) throw error; - return failed("invalid"); + const requests = ["/api/system/health", "/api/providers"].map(path => + Promise.resolve().then(() => fetch(`${apiBase}${path}`, { signal: requestSignal })).then(observe)); + // Access failure must not be lost behind a rejected or stalled peer request. + const result = await Promise.race([Promise.allSettled(requests), accessChanged]); + signal.throwIfAborted(); + if (result === "access") return failed(accessFailure ?? "auth"); + const [hResult, pResult] = result; + if (hResult.status === "rejected" || pResult.status === "rejected") return failed("unavailable"); + const hRes = hResult.value; + const pRes = pResult.value; + if (!hRes.ok || !pRes.ok) return failed("request"); + try { + const health = await requireJson(hRes); + const providers = await requireJson(pRes); + if (!health || typeof health.status !== "string" || typeof health.version !== "string" + || !Number.isFinite(health.uptime) || health.uptime < 0 + || !Array.isArray(providers) || providers.some(row => !row || typeof row.name !== "string" + || typeof row.adapter !== "string" || typeof row.baseUrl !== "string" || typeof row.hasApiKey !== "boolean" + || (row.defaultModel !== undefined && typeof row.defaultModel !== "string"))) return failed("invalid"); + return { health, providers, error: false }; + } catch (error) { + if (isAbortError(error, signal)) throw error; + return failed("invalid"); + } + } finally { + controller.abort(); } } diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 410e774edc..fdff9caf08 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -1,3 +1,4 @@ +import { classifyDataSurface } from "../data-surface"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { useKeyedClientResource } from "../client-resource"; import { replaceHash } from "../hash-routing"; @@ -279,6 +280,7 @@ export function useDashboardData(apiBase: string, refreshEpoch = 0) { (signal) => fetchDashboardOverview(apiBase, signal), { pollMs: 5000 }, ); + const overviewSurface = classifyDataSurface(overviewPoll, data => data.health === null, true); const overviewReady = health !== null || overviewPoll.data !== undefined; // Preferences that are just config — never gate on overview or injection. @@ -850,8 +852,8 @@ export function useDashboardData(apiBase: string, refreshEpoch = 0) { effortCap, subagentEffortCap, effortCapSaving, setEffortCap, setSubagentEffortCap, setEffortCapSaving, syncResult, syncError, projectConfigWarnings, updateOpen, updateChannel, setUpdateRestart, updateRestart, updateLoading, - updateCheck, updateError, updateJob, reconnecting, error, - connectionFailure: overviewPoll.data?.failure, refreshDashboard: overviewPoll.refresh, + updateCheck, updateError, updateJob, reconnecting, error: error || overviewSurface.showError, + connectionFailure: overviewPoll.data?.failure ?? (overviewSurface.showError ? "unavailable" : undefined), refreshDashboard: overviewPoll.refresh, effortCapHelpTriggerRef, updateTriggerRef, maHelpTriggerRef, shadowCallHelpTriggerRef, effortCapHelpDialogRef, updateDialogRef, maHelpDialogRef, shadowCallHelpDialogRef, filteredGroups, sidecarModels, visionModels, diff --git a/gui/tests/dashboard-connection-state.test.ts b/gui/tests/dashboard-connection-state.test.ts index 7597d9bc04..9628ce1628 100644 --- a/gui/tests/dashboard-connection-state.test.ts +++ b/gui/tests/dashboard-connection-state.test.ts @@ -32,3 +32,19 @@ test("transport failure is distinct from cancelled polling", async () => { const controller = new AbortController(); controller.abort(); await expect(fetchDashboardOverview("", controller.signal)).rejects.toThrow(); }); + +for (const deniedPath of ["/api/system/health", "/api/providers"]) { + test(`a 403 from ${deniedPath} outranks a rejected peer`, async () => { + globalThis.fetch = (async input => { + if (String(input).endsWith(deniedPath)) return new Response(null, { status: 403 }); + throw new TypeError("network failed"); + }) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toMatchObject({ failure: "denied" }); + }); + test(`a 403 from ${deniedPath} does not wait for a stalled peer`, async () => { + globalThis.fetch = ((input, init) => String(input).endsWith(deniedPath) + ? Promise.resolve(new Response(null, { status: 403 })) + : new Promise((_resolve, reject) => { init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true }); })) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toMatchObject({ failure: "denied" }); + }); +} diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 21fdd69da2..84296ccfc6 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -545,3 +545,5 @@ integration IO adapter. Its snapshot fingerprint cannot be checked against provi [the integration contract](clients/integrations.md#cline-paired-files) defines recovery. The existing dashboard file-client maps include Cline CLI and reuse its committed color mark. The export panel labels its download as a settings/catalog bundle; all locales explain that Undo restores both original files. + +Dashboard overview polling observes authorization failures independently of stalled or rejected peer requests, cancels remaining child requests after a decisive result, and exposes resource-level deadline failures without rewriting them as authentication failures. From 52c8e8a902f09954f8baa3c42c4ad0b4474434f8 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 21:12:55 +0900 Subject: [PATCH 3/4] fix(pairing): use erasable types and explicit JSX event handlers --- devlog/_plan/260912_operations/050_pairing.md | 4 +- gui/src/connect-pairing-transport.ts | 4 +- gui/src/connect-pairing.ts | 76 ------------------- gui/src/connect-pairing.tsx | 71 +++++++++++++++++ gui/tests/connect-pairing.test.ts | 2 + .../dashboard-resource-deadline.test.tsx | 75 ++++++++++++++++++ 6 files changed, 154 insertions(+), 78 deletions(-) delete mode 100644 gui/src/connect-pairing.ts create mode 100644 gui/src/connect-pairing.tsx create mode 100644 gui/tests/dashboard-resource-deadline.test.tsx diff --git a/devlog/_plan/260912_operations/050_pairing.md b/devlog/_plan/260912_operations/050_pairing.md index 8aad40d093..009e828d58 100644 --- a/devlog/_plan/260912_operations/050_pairing.md +++ b/devlog/_plan/260912_operations/050_pairing.md @@ -4,7 +4,7 @@ Class C3; dependency roadmap. Reuse existing connected-client state and browser- MODIFY owning dashboard pending-auth component and bootstrap state: distinguish a reachable connected machine awaiting hub browser authentication from a stopped standalone proxy. Show configured hub identity/origin, explain that machine enrollment and browser session are separate, offer the current origin-specific existing pairing/authentication action. Preserve revoked/expired/unreachable states and their existing retry actions; do not suggest ocx start while the local runtime is reachable. Derive the next action from current origin + configured hub instead of a hardcoded localhost URL. No credentials appear in visible copy/URLs. -MODIFY all gui/src/i18n locale dictionaries with meaningful labels. Extend existing pending-auth/dashboard tests for local origin, remote hub origin, pending, authenticated, expired/revoked and unavailable standalone; positive browser auth transitions into connected dashboard. Exact files: gui/src/App.tsx, api.ts, pages/dashboard-core-poll.ts, pages/use-dashboard-data.ts and pages/Dashboard.tsx consume a classified authentication/error state instead of a boolean. Existing connect-pairing.ts and connect-pairing-transport.ts own hub identity and origin-specific action. Define the error classification in api.ts at response ingress; consume in polling and Dashboard; reset on authenticated success and pairing completion. No persistence/serialization for this UI state. Keep cached data with stale labeling when auth fails; do not erase a known hub into standalone offline. Public hub/browser-pairing guidance is updated with the same distinction. No service restart or live auth reconfiguration. +MODIFY all gui/src/i18n locale dictionaries with meaningful labels. Extend existing pending-auth/dashboard tests for local origin, remote hub origin, pending, authenticated, expired/revoked and unavailable standalone; positive browser auth transitions into connected dashboard. Exact files: gui/src/App.tsx, api.ts, pages/dashboard-core-poll.ts, pages/use-dashboard-data.ts and pages/Dashboard.tsx consume a classified authentication/error state instead of a boolean. Existing connect-pairing.tsx and connect-pairing-transport.ts own hub identity and origin-specific action. Define the error classification in api.ts at response ingress; consume in polling and Dashboard; reset on authenticated success and pairing completion. No persistence/serialization for this UI state. Keep cached data with stale labeling when auth fails; do not erase a known hub into standalone offline. Public hub/browser-pairing guidance is updated with the same distinction. No service restart or live auth reconfiguration. Hosted component suite and screenshot artifact of the rendered pending state required for final delivery; local GUI tests/build NOT RUN. Static source or mockup is not rendered application evidence. @@ -27,3 +27,5 @@ Pairing lifetime precision: form keyed by target server/bootstrap identity, one A amendment: post-pairing refresh explicitly reaches each dashboard keyed resource through [apiBase, refreshEpoch] dependencies; a component remount is not treated as a cache invalidation mechanism. Regression first seeds a failed overview store, completes pairing, and requires a new authenticated health/provider read plus rendered data. Reflection03/05 closure:403 keeps distinct permission-denied guidance and never starts or re-pairs a running proxy merely for denied permissions. Validate HealthData status/version strings and finite nonnegative uptime; providers must be an array of objects with the required name/adapter/baseUrl strings and hasApiKey boolean, optional defaultModel string. Invalid shapes are classified invalid even with HTTP200. Unauthorized/denied content stays hidden; only nonauth read failure may show cached data with stale notice. + +Resume C repairs: hosted34682559994 found erasableSyntaxOnly constructor parameter-property and React ref analysis at createElement form. Explicit class field and JSX component preserve behavior without disabling rules. Source compound-failure repair was already published externally at4124a644; local byte-identical patch preserved before fast-forward. Hidden-document App fixture disables periodic polls, and a controlled real resource deadline verifies retained data becomes stale. All local suites/build/typecheck/install NOT RUN. diff --git a/gui/src/connect-pairing-transport.ts b/gui/src/connect-pairing-transport.ts index cba4958538..f83dcf1d75 100644 --- a/gui/src/connect-pairing-transport.ts +++ b/gui/src/connect-pairing-transport.ts @@ -4,8 +4,10 @@ import type { ApiTarget } from "./api-targets"; const PAIRING_CODE = /^ocx_pair_[A-Za-z0-9_-]{43}$/; export class PairingError extends Error { - constructor(readonly kind: "invalid-code" | "refused" | "unreachable" | "request-failed" | "invalid-response") { + readonly kind: "invalid-code" | "refused" | "unreachable" | "request-failed" | "invalid-response"; + constructor(kind: PairingError["kind"]) { super(`pairing_${kind}`); + this.kind = kind; this.name = "PairingError"; } } diff --git a/gui/src/connect-pairing.ts b/gui/src/connect-pairing.ts deleted file mode 100644 index 5d63b51af9..0000000000 --- a/gui/src/connect-pairing.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { createElement, useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react"; -import type { ApiTarget } from "./api-targets"; -import { useT } from "./i18n/shared"; -import { PairingError, submitConnectPairing } from "./connect-pairing-transport"; -import { useCopyFeedback } from "./components/use-copy-feedback"; - -export function ConnectPairingForm({ - target, - onConnected, -}: { - target: ApiTarget; - onConnected: () => void; -}) { - const t = useT(); - const [grant, setGrant] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const activeRequest = useRef(null); - useEffect(() => () => activeRequest.current?.abort(), []); - const copyFeedback = useCopyFeedback(); - const command = `ocx gui pair --origin "${window.location.origin}"`; - const copied = copyFeedback.outcomeFor(command); - - const submit = async (event: FormEvent) => { - event.preventDefault(); - if (busy) return; - setBusy(true); - setError(null); - const controller = new AbortController(); - activeRequest.current = controller; - try { - await submitConnectPairing(target, grant, undefined, controller.signal); - if (!controller.signal.aborted) onConnected(); - } catch (failure) { - if (!controller.signal.aborted) setError(failure instanceof PairingError ? failure.kind : "unreachable"); - } finally { - if (!controller.signal.aborted) setBusy(false); - if (activeRequest.current === controller) activeRequest.current = null; - } - }; - - return createElement("section", { className: "card connect-pairing", "aria-labelledby": "connect-pairing-title" }, - createElement("h2", { id: "connect-pairing-title" }, t("connection.pairing.title")), - createElement("p", null, t("connection.pairing.hub"), ": ", createElement("code", null, target.serverOrigin)), - createElement("p", null, t("connection.pairing.getCode")), - createElement("pre", { style: { whiteSpace: "pre-wrap", overflowWrap: "anywhere" } }, createElement("code", null, command)), - createElement("button", { type: "button", className: "btn btn-ghost", onClick: () => copyFeedback.copy(command, command) }, - t(copied === "copied" ? "startup.copied" : "startup.copy")), - copied === "unavailable" ? createElement("p", { role: "status" }, t("prov.linkCopyUnavailable")) : null, - createElement("p", null, t("connection.pairing.askOperator")), - createElement("p", null, t("connection.pairing.notApiKey")), - createElement("form", { onSubmit: submit, className: "api-form-row" }, - createElement("label", { htmlFor: "connect-pairing-code", className: "field-label" }, t("connection.pairing.code")), - createElement("input", { - id: "connect-pairing-code", - name: "pairingCode", - value: grant, - onChange: (event: ChangeEvent) => setGrant(event.currentTarget.value), - autoComplete: "off", - spellCheck: false, - disabled: busy, - className: "input mono", - "aria-invalid": Boolean(error) || undefined, - "aria-describedby": error ? "connect-pairing-error" : undefined, - }), - createElement("button", { type: "submit", className: "btn btn-primary", disabled: busy || !grant.trim() }, - t(busy ? "connection.pairing.submitting" : "connection.pairing.submit")), - error ? createElement("p", { id: "connect-pairing-error", className: "alert alert-err", role: "alert" }, - t(error === "invalid-code" ? "connection.pairing.notApiKey" - : error === "unreachable" ? "connection.pairing.networkError" - : error === "request-failed" ? "connection.pairing.requestError" - : error === "invalid-response" ? "connection.pairing.responseError" : "connection.pairing.error")) : null, - ), - target.transport === "relay" ? createElement("p", { className: "text-muted" }, t("connection.pairing.relayWarning")) : null, - ); -} diff --git a/gui/src/connect-pairing.tsx b/gui/src/connect-pairing.tsx new file mode 100644 index 0000000000..c6c9750b1a --- /dev/null +++ b/gui/src/connect-pairing.tsx @@ -0,0 +1,71 @@ +import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react"; +import type { ApiTarget } from "./api-targets"; +import { useT } from "./i18n/shared"; +import { PairingError, submitConnectPairing } from "./connect-pairing-transport"; +import { useCopyFeedback } from "./components/use-copy-feedback"; + +export function ConnectPairingForm({ + target, + onConnected, +}: { + target: ApiTarget; + onConnected: () => void; +}) { + const t = useT(); + const [grant, setGrant] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const activeRequest = useRef(null); + useEffect(() => () => activeRequest.current?.abort(), []); + const copyFeedback = useCopyFeedback(); + const command = `ocx gui pair --origin "${window.location.origin}"`; + const copied = copyFeedback.outcomeFor(command); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (busy) return; + setBusy(true); + setError(null); + const controller = new AbortController(); + activeRequest.current = controller; + try { + await submitConnectPairing(target, grant, undefined, controller.signal); + if (!controller.signal.aborted) onConnected(); + } catch (failure) { + if (!controller.signal.aborted) setError(failure instanceof PairingError ? failure.kind : "unreachable"); + } finally { + if (!controller.signal.aborted) setBusy(false); + if (activeRequest.current === controller) activeRequest.current = null; + } + }; + + return
+

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

+

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

+

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

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

{t("prov.linkCopyUnavailable")}

} +

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

+

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

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

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

} +
; +} diff --git a/gui/tests/connect-pairing.test.ts b/gui/tests/connect-pairing.test.ts index e65ed3c6be..05fcaa4239 100644 --- a/gui/tests/connect-pairing.test.ts +++ b/gui/tests/connect-pairing.test.ts @@ -6,6 +6,8 @@ test("App mounts the relay pairing form and installs only the returned shared se const keys = ["window", "document", "navigator", "sessionStorage", "localStorage", "fetch", "confirm", "alert", "IS_REACT_ACT_ENVIRONMENT", "__APP_VERSION__"] as const; const previous = Object.fromEntries(keys.map(key => [key, Reflect.get(globalThis, key)])); const win = new Window({ url: "http://localhost/#dashboard" }); + // Hidden documents have no periodic resource poll: pairing must explicitly revalidate. + Object.defineProperty(win.document, "visibilityState", { configurable: true, value: "hidden" }); Object.defineProperties(globalThis, { window: { configurable: true, value: win }, document: { configurable: true, value: win.document }, diff --git a/gui/tests/dashboard-resource-deadline.test.tsx b/gui/tests/dashboard-resource-deadline.test.tsx new file mode 100644 index 0000000000..be66d644c4 --- /dev/null +++ b/gui/tests/dashboard-resource-deadline.test.tsx @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { LanguageProvider } from "../src/i18n/provider"; +import { useDashboardData } from "../src/pages/use-dashboard-data"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; + +test("a controlled overview deadline marks retained dashboard data stale", async () => { + const keys = ["window", "document", "navigator", "sessionStorage", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; + const previous = new Map(keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + const win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.document, "visibilityState", { configurable: true, value: "hidden" }); + for (const [key, value] of Object.entries({ window: win, document: win.document, navigator: win.navigator, + sessionStorage: win.sessionStorage, localStorage: win.localStorage, IS_REACT_ACT_ENVIRONMENT: true })) { + Object.defineProperty(globalThis, key, { configurable: true, value }); + } + const originalTimeout = globalThis.setTimeout; + let deadline: (() => void) | undefined; + let stall = false; + let started = false; + Object.defineProperty(globalThis, "setTimeout", { configurable: true, value: (callback: (...args: unknown[]) => void, ms?: number, ...args: unknown[]) => { + if (stall && ms === 30_000 && !deadline) deadline = () => callback(...args); + return originalTimeout(callback, ms, ...args); + } }); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (path.endsWith("/api/system/health")) { + if (stall) { + started = true; + return new Promise((_resolve, reject) => init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true })); + } + return Response.json({ status: "ok", version: "fixture", uptime: 10 }); + } + if (path.endsWith("/api/providers") || path.endsWith("/api/models")) return Response.json([]); + return new Response(null, { status: 404 }); + } }); + clearClientResourceStoresForTests(); + let data: ReturnType | undefined; + function Probe() { + data = useDashboardData("/deadline"); + return {data.error ? "stale" : "fresh"}:{data.health?.version}; + } + const { createRoot } = await import("react-dom/client"); + const host = win.document.createElement("div"); + win.document.body.append(host); + const root = createRoot(host); + const waitFor = async (predicate: () => boolean) => { + const until = Date.now() + 5000; + while (!predicate()) { + if (Date.now() >= until) throw new Error("dashboard fixture did not settle"); + await act(async () => { await new Promise(resolve => setImmediate(resolve)); }); + } + }; + try { + await act(async () => { root.render(); }); + await waitFor(() => data?.health?.version === "fixture"); + expect(host.textContent).toBe("fresh:fixture"); + stall = true; + await act(async () => { data!.refreshDashboard(); }); + await waitFor(() => started && deadline !== undefined); + await act(async () => { deadline!(); }); + await waitFor(() => data?.error === true); + expect(host.textContent).toBe("stale:fixture"); + expect(data!.connectionFailure).toBe("unavailable"); + } finally { + await act(async () => { root.unmount(); }); + clearClientResourceStoresForTests(); + Object.defineProperty(globalThis, "setTimeout", { configurable: true, writable: true, value: originalTimeout }); + win.close(); + for (const key of keys) { + const descriptor = previous.get(key); + if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); + } + } +}); From 3e73ca4b2deec44914286dc9ab87122ccd8e1c43 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 21:17:49 +0900 Subject: [PATCH 4/4] docs: record operations resume and remaining verification --- devlog/_plan/260912_operations/110_resume_status.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 devlog/_plan/260912_operations/110_resume_status.md diff --git a/devlog/_plan/260912_operations/110_resume_status.md b/devlog/_plan/260912_operations/110_resume_status.md new file mode 100644 index 0000000000..2ae5182e88 --- /dev/null +++ b/devlog/_plan/260912_operations/110_resume_status.md @@ -0,0 +1,9 @@ +# Operations resume checkpoint + +Update carry #4343 merged with exact candidate f37894711158fa8215d26bed642389148ac395f6 and successful Cross-platform CI34674523305. The already-carried stop fix is not replayed. Original issue/PR closure stays with integration coordination. + +Listener #4353, usage #4357 → #4373 and pairing #4378 remain open. Published usage source/security audits passed; hosted execution must be checked on the final cumulative tip. Prior Cline registration/native-restore fixture failures are retained as failures; current dev has a separate repair, and this unit does not duplicate its ownership. + +Pairing resumed at persisted C. The previous local compound-failure patch matched the newer remote commit byte-for-byte and was preserved before fast-forward. The subsequent fix uses an erasable explicit error field and JSX event handlers for the hosted compiler/lint failures; no checks were disabled. A hidden-document pairing fixture excludes periodic polling, and a controlled resource deadline verifies stale-data marking. Hosted execution and rendered preview remain pending. + +The OpenCode management-token and local transport change remains outstanding against original #4317 CHANGES_REQUESTED. No whole-lane completion is claimed. All local suites, focused tests, GUI tests, build, typecheck and installs are NOT RUN. No service changes or release actions were performed.