Skip to content

feat(windows): serve the dashboard from dashboard-api in the Windows Desktop profile - #7978

Open
IronicRayquaza wants to merge 6 commits into
Osmantic:mainfrom
IronicRayquaza:feat/windows-desktop-serve-dashboard
Open

IronicRayquaza wants to merge 6 commits into
Osmantic:mainfrom
IronicRayquaza:feat/windows-desktop-serve-dashboard

Conversation

@IronicRayquaza

@IronicRayquaza IronicRayquaza commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fifth step of #7968: dashboard-api serves the dashboard itself in the Windows Desktop profile. With no nginx, the Desktop app window (an Edge app window on a loopback URL) needs one listener that serves the UI and decides who gets the server-held DASHBOARD_API_KEY.

Builds on #7970. The first three commits are #7970's; please review the last two. I'll rebase once #7970 merges. The last commit is a 3-line test change: it gives the startup probe the two local-model settings #7975 requires, so this PR and #7975 pass in either merge order. Without #7975 the app ignores them.

It applies only when the launcher sets ODS_DASHBOARD_DIST to an absolute built dashboard folder; the Compose profile never loads it.

native_front.DashboardGate (ASGI middleware) ports the stricter twin of nginx's gate, the Vite dev gate dev-api-access.js localRequest(). A request receives the key without a session only when all of these hold:

  • the peer is loopback;
  • Host is localhost, 127.0.0.1 or [::1] with this listener's port;
  • there are no Forwarded / Via / X-Real-IP / X-Forwarded-* headers;
  • Host, Origin and Sec-Fetch-Site are not duplicated;
  • Origin and Sec-Fetch-Site are same-origin or absent.

Why the stricter rules: on a home PC the browser visits arbitrary websites, any of which can send requests to http://localhost:<port>. Under these rules DNS rebinding, cross-site requests and CSRF all count as untrusted. Untrusted requests need a dashboard session, checked in-process with the existing session_is_valid(); otherwise they get nginx's own 401 body with X-ODS-Sign-In: required.

A client's own Authorization header is always removed. Talk and dashboard sign-in/out never receive the admin key, matching nginx.

native_front.DashboardFiles serves the build with:

  • SPA fallback for client routes;
  • nginx's cache rules: hashed assets/ immutable for a year, everything else no-cache;
  • nginx's security headers and CSP, without the Pixel preview origins, since there is no preview here. These are on every response, including the gate's own 401 and 404.

Unknown /api/* paths return a JSON 404, as nginx forwards them, never the dashboard page with a 200. /pixel-preview/* is 404. The / mount is registered after every @app route, so no API route is shadowed. An early version had exactly that bug; the integration test below now guards it.

AI Assistance

Claude Code drafted the code, tests and this description, and ran the validation below on my Windows 11 laptop. I chose the scope and design (see #7968), and I'm accountable for the diff and for responding to review.

Release Lane

  • Stable hotfix targeting release/2.6.x
  • Mainline change targeting main
  • Next-minor work targeting the next feature/minor release
  • Not sure; reviewer should help classify

Changed Surface

  • Docs only
  • Tests only
  • Dashboard UI
  • Dashboard API / host agent
  • Installer / bootstrap / lifecycle
  • Docker Compose / service manifests
  • Model routing / Hermes / capabilities
  • Network exposure / auth / proxy
  • Dependencies / runtime wiring

Risk And Validation

  • Risk level: Medium. This is an authentication boundary, so it's scoped to the Windows Desktop profile with a dashboard path set; the Compose profile is unchanged.
  • Validation run:
tests/test_native_front.py (29): the localRequest() matrix
  admitted: 127.0.0.1 / localhost / [::1] on the listener port; same-origin Origin; Sec-Fetch-Site same-origin|none
  refused (401 + X-ODS-Sign-In): Host evil.example (rebinding), Host localhost:9999, X-Forwarded-For, Forwarded,
    Via, X-Real-IP, Origin evil, Origin null, Sec-Fetch-Site cross-site|same-site, duplicate Host, network peer,
    forged session cookie
  a valid session cookie admits a network peer; a client Authorization is replaced, never passed on
  talk and sign-in never receive the key; /pixel-preview 404; SPA routes; asset caching; unknown /api -> JSON 404;
  traversal (/../, %2e%2e, ..%2f) never serves outside the build; security headers on every response
tests/test_windows_desktop_dashboard.py: the real main.py in the Desktop profile, in a fresh interpreter:
  / and /settings serve the build; /api/status from the local browser without a key -> 200 windows-desktop;
  network peer and cross-site -> 401; unknown /api path -> 404 (not index.html)

Live, on Windows 11 Home 26200 (i5-10500H): dashboard-api from this branch on #7972's pinned pythonw.exe (stdout and stderr redirected), probed with curl.exe:

started in 3.0s (pythonw)
dashboard page                                 -> 200
client route /settings (SPA)                   -> 200
/api/status, local browser, no key sent        -> 200
/api/status via http://localhost               -> 200 (0.25s: tries ::1 first; 127.0.0.1 answers in 0.04s)
DNS rebinding Host: evil.example               -> 401 sign-in
another website (Sec-Fetch-Site: cross-site)   -> 401 sign-in
another website (Origin: evil)                 -> 401 sign-in
a proxy in front (X-Forwarded-For)             -> 401 sign-in
cross-site POST (CSRF)                         -> 401 sign-in
spoofed Authorization from a website           -> 401 sign-in
unknown API path                               -> 404
pixel preview                                  -> 404
security headers on /: content-security-policy, x-frame-options, x-content-type-options
cache on hashed asset: public, max-age=31536000, immutable
listeners: 127.0.0.1:3101 only        children of pythonw: 0

Related existing tests (-k "session or auth or main or status or csrf or origin") match main. One async ordering test (test_pixel_agent_teams.py::test_real_session_ids_order_handoff_and_idempotency) failed once while the laptop was under load, then passed 4/4 on this branch, #7970 and main.

Operational Change Check

  • This is not an operational change.
  • This is an operational change and validation is recorded above.
  • This is an operational change and validation is intentionally deferred for:

Network exposure and auth are touched for the Windows Desktop profile only. The listener is loopback (PR6's launcher binds 127.0.0.1).

Notes For Reviewers

  • The live run used a stand-in dashboard. Building the real UI needs npm ci, which comes with the UI change in PR4. The gate doesn't depend on the UI's contents.
  • Shared PCs (question 5 on RFC: ODS Windows Desktop — run the real dashboard and model natively on Windows (no Docker, no WSL) #7968): like nginx's gate, any process of any Windows user on this PC that speaks loopback HTTP with a correct Host is trusted. Requiring a session cookie, or checking the peer process's owner, is a follow-up if you want it.
  • For PR6: point the app shortcut at http://127.0.0.1:<port>/ rather than localhost (0.2 s per new connection lost to the IPv6 attempt).

IronicRayquaza and others added 4 commits October 23, 2026 01:52
Add an opt-in ODS_RUNTIME_PROFILE=windows-desktop so dashboard-api can run
as a native Windows process with no Docker, no WSL and no host agent.

- Refuse to start without an absolute ODS_DATA_DIR and DASHBOARD_API_KEY;
  the generated-key fallback no longer writes to /data (C:\data on Windows).
- Never dial a host agent: AGENT_URL is empty and the shared client raises
  AgentUnavailable before any socket, so a Docker or WSL ODS on the same PC
  cannot answer.
- Start no child processes: GPU probes, the curl cluster refresh, Token Spy
  polling and the Docker preflight are skipped. A windowless process would
  otherwise flash a console every five seconds.
- Load only loopback services, since Compose service names resolve to
  nothing on a native PC and each failed lookup stalls /api/status.
- Report Windows CPU and RAM through kernel32 instead of None.
- Add a path-filtered windows-latest workflow that installs the hashed lock
  wheels-only and runs the profile tests.

Refs Osmantic#7968

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mypy runs on Linux, where ctypes has no windll or WinError, so assert the
Windows platform inside the two kernel32 readers. Count lock hashes in a
typed dict instead of a dict mixing names and counters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mypy 2.4 does not treat an assert on sys.platform as unreachable, so define
the two readers under a module-level sys.platform == "win32" branch, with
stubs that raise OSError elsewhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Desktop profile

The Windows Desktop profile (Osmantic#7968) has no nginx, so dashboard-api is the
only listener. When the launcher sets ODS_DASHBOARD_DIST to the built
dashboard, dashboard-api serves it and applies the boundary nginx applies:
who receives the server-held DASHBOARD_API_KEY.

native_front.DashboardGate ports the stricter twin of nginx's gate, the Vite
dev gate (dev-api-access.js). A request gets the key without a session only
from a loopback peer, with a loopback Host naming this listener's port, no
forwarding headers, no duplicated Host/Origin/Sec-Fetch-Site, and a
same-origin or absent Origin and Sec-Fetch-Site. A website that sends a
request to localhost (DNS rebinding, cross-site, CSRF) therefore needs a
dashboard session; otherwise it gets nginx's 401 with X-ODS-Sign-In. A
client's own Authorization header is always replaced, never trusted. Talk and
dashboard sign-in never receive the admin key. Sessions are verified
in-process with the existing session_is_valid().

DashboardFiles serves the build with SPA fallback for client routes,
nginx's cache rules (hashed assets immutable, everything else no-cache),
nginx's security headers and CSP without the Pixel preview origins, and a
JSON 404 for unknown API paths instead of the dashboard page. /pixel-preview
does not exist in this profile. The mount is registered after every API
route so none is shadowed.

Refs Osmantic#7968

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ngs Osmantic#7975 requires

Osmantic#7978's startup probe predates Osmantic#7975, which refuses a Desktop start
without ODS_NATIVE_LLM_URL and ODS_NATIVE_LLM_RUNTIME_DIR. Whichever of
the two merges second carries this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Lightheartdevs

Copy link
Copy Markdown
Collaborator

Thanks for bringing the native dashboard's access checks and routing behavior into a testable ASGI boundary.

Advisory status: not ready yet

Quickest path to merge:

  • In ods/extensions/services/dashboard-api/main.py:1966, make the direct entrypoint bind 127.0.0.1 when RUNTIME_PROFILE.is_desktop; retain 0.0.0.0 for Compose. Running python main.py with the desktop profile currently passes host="0.0.0.0" to Uvicorn, even with ODS_DASHBOARD_DIST set. The API gate rejects unauthenticated network peers, but the listener and static dashboard are still exposed beyond loopback. PR6's planned launcher bind does not constrain this entrypoint. Add a regression that intercepts uvicorn.run under both profiles and checks the selected host.

What I checked:

  • Full diff, current repository boundaries, nginx/dev-gate behavior, surrounding profile/auth code, and CI wiring; git diff --check passed. All 72 reported CI checks passed at this head.
  • Independently ran test_native_front.py and test_windows_desktop_dashboard.py: 30 passed on Windows. A fresh-interpreter probe of the desktop __main__ path captured host="0.0.0.0" without opening a listener.
  • The gate tests are covered by the existing dashboard workflow's pytest tests/ invocation. feat(windows): run dashboard-api natively as a windows-desktop profile #7970 remains open, so refresh the combined result after the planned parent merge/rebase.

`python main.py` passed host="0.0.0.0" to uvicorn in every profile, so a
Desktop process started that way listened beyond this PC; only the PR6
launcher's --host kept installs on 127.0.0.1. RuntimeProfile.listen_host
now makes loopback part of the profile (Compose keeps 0.0.0.0 for Docker
publishing), and a fresh-interpreter test runs main.py as __main__ with
uvicorn.run intercepted under both profiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IronicRayquaza

Copy link
Copy Markdown
Contributor Author

Thanks, good catch: the launcher's bind protected the installed path, but not python main.py itself.

Fixed in 7a305bf:

  • RuntimeProfile.listen_host is 127.0.0.1 for windows-desktop and stays 0.0.0.0 for Compose, and the direct entrypoint uses it (main.py).
  • test_direct_entrypoint_binds_the_profile_host runs main.py as __main__ in a fresh interpreter for both profiles with uvicorn.run intercepted, so no listener opens, and asserts host and port. On the previous head the desktop case fails with 0.0.0.0 and the Compose case passes.
  • test_runtime_profile.py covers the property directly.

On Windows (Python 3.13), these pass:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants