-
Notifications
You must be signed in to change notification settings - Fork 870
feat(server): authenticated remote dashboard listener with cookie sessions #2414
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -12,6 +12,7 @@ runs helper features around provider requests. | |||||||||||||||
| | --- | --- | --- | --- | | ||||||||||||||||
| | `port` | `number` | `10100` | Proxy listen port. | | ||||||||||||||||
| | `hostname?` | `string` | `"127.0.0.1"` | Bind address. Non-loopback binds require `OPENCODEX_API_AUTH_TOKEN`. | | ||||||||||||||||
| | `dashboardListener?` | `{ enabled?: boolean; port: number; hostname: string }` | off | Opt-in second listener serving only the dashboard and `/api/*` on a private/tailnet address. The main proxy listener is unchanged. See [Remote dashboard access](/guides/web-dashboard/#remote-dashboard-access-tailscale). | | ||||||||||||||||
| | `proxy?` | `string` | — | Outbound HTTP(S) proxy URL or `${ENV_VAR}`. Applied to `HTTP_PROXY` / `HTTPS_PROXY` only when those variables are unset; loopback remains in `NO_PROXY`. | | ||||||||||||||||
| | `emptyCompletionRetry?` | `boolean` | `false` | Opt in to one identical Responses retry when a completion has no text or tool call. The retry may be billable. `OCX_EMPTY_COMPLETION_RETRY=0` disables it without changing config; combo and routed-compaction turns remain excluded. | | ||||||||||||||||
| | `stallTimeoutSec?` | `number` | `300` | Seconds without upstream data before `response.incomplete`. Minimum 1. | | ||||||||||||||||
|
|
@@ -110,6 +111,39 @@ a page you visit can make your browser connect to `127.0.0.1`. The listener ther | |||||||||||||||
| same `Host` and `Origin` checks as an ordinary loopback bind. Off by default. | ||||||||||||||||
| ::: | ||||||||||||||||
|
|
||||||||||||||||
| ### Remote dashboard listener | ||||||||||||||||
|
|
||||||||||||||||
| `dashboardListener` opens a second listener that serves only the dashboard: the web app, the session | ||||||||||||||||
| bootstrap path `/opencodex-session`, and the `/api/*` management API. The main proxy listener — and | ||||||||||||||||
| its `hostname` and `port` — are completely unchanged; Codex and every other client keep using the | ||||||||||||||||
| original `127.0.0.1` address exactly as before. | ||||||||||||||||
|
|
||||||||||||||||
| ```json | ||||||||||||||||
| { | ||||||||||||||||
| "dashboardListener": { | ||||||||||||||||
| "enabled": true, | ||||||||||||||||
| "port": 10101, | ||||||||||||||||
| "hostname": "100.88.9.100" | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| ``` | ||||||||||||||||
|
|
||||||||||||||||
| `hostname` is required when the listener is enabled and must be a specific non-blank bind address, | ||||||||||||||||
| typically the machine's Tailscale IP (`100.x.y.z`). The key is absent by default; an | ||||||||||||||||
| `{ "enabled": false }` shape is also accepted. `port` must differ from the proxy `port` and from | ||||||||||||||||
| `unauthenticatedLoopbackListener`'s port; a collision is a write-time validation error. | ||||||||||||||||
|
|
||||||||||||||||
| Every data-plane route is refused on this listener: `/v1/*` (Responses, Chat Completions, models, | ||||||||||||||||
| Messages, Realtime/live), data-plane WebSocket upgrades, and `/readyz` all return `404`. `GET /healthz` is served because the dashboard itself polls it; it returns the standard health payload — service name, version, uptime, pid, port, and restart/provider-reload capability flags — operational metadata only, with no credentials or provider data. | ||||||||||||||||
| Management calls still require the existing admin token (`OPENCODEX_ADMIN_AUTH_TOKEN` or the | ||||||||||||||||
| generated `~/.opencodex/admin-api-token` file) — the same credential the local dashboard uses. The | ||||||||||||||||
| bind address should be a private or tailnet address; see | ||||||||||||||||
| [Remote dashboard access (Tailscale)](/guides/web-dashboard/#remote-dashboard-access-tailscale). | ||||||||||||||||
|
|
||||||||||||||||
| Session endpoints exist on every listener: `POST /api/auth/session` with the admin token in | ||||||||||||||||
| `X-OpenCodex-API-Key` mints a 12-hour GUI session cookie, and `GET /api/auth/session` returns the | ||||||||||||||||
| current session's `csrfToken`, `origin`, and `expiresAt`. | ||||||||||||||||
|
Comment on lines
+143
to
+145
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Correct the "every listener" claim; the unauthenticated loopback listener refuses these endpoints. Line 143 states that the session endpoints exist on every listener. That contradicts line 102 of this same document, which states that the The code agrees with line 102. A reader who enables both listeners would follow line 143 and receive a Restrict the claim to the proxy listener and the dashboard listener. 📝 Proposed documentation fix-Session endpoints exist on every listener: `POST /api/auth/session` with the admin token in
-`X-OpenCodex-API-Key` mints a 12-hour GUI session cookie, and `GET /api/auth/session` returns the
-current session's `csrfToken`, `origin`, and `expiresAt`.
+Session endpoints exist on the proxy listener and this dashboard listener: `POST /api/auth/session`
+with the admin token in `X-OpenCodex-API-Key` mints a 12-hour GUI session cookie, and
+`GET /api/auth/session` returns the current session's `csrfToken`, `origin`, and `expiresAt`.
+`unauthenticatedLoopbackListener` serves neither endpoint; it returns `404` for all of `/api/*`.As per path instructions: "Check that user-facing docs stay in sync with actual CLI/API behavior." 📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Path instructions |
||||||||||||||||
|
|
||||||||||||||||
| ### SSH port forwarding | ||||||||||||||||
|
|
||||||||||||||||
| Remote use does not require a remote bind. Keep loopback and forward it: | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,8 @@ let requestAdminToken: AdminTokenPrompt = promptForAdminToken; | |
| const SESSION_REBOOTSTRAP_PATH = "/opencodex-session"; | ||
| /** Safe authenticated read used to validate a raw admin token before closing the sign-in form. */ | ||
| const ADMIN_TOKEN_VALIDATION_PATH = "/api/settings"; | ||
| /** Cookie-session endpoint: GET probes the HttpOnly cookie, POST mints one from an admin token. */ | ||
| const AUTH_SESSION_PATH = "/api/auth/session"; | ||
|
|
||
| /** | ||
| * The silent re-bootstrap must fail fast: every /api/* request queues behind the | ||
|
|
@@ -69,6 +71,14 @@ let memoryToken: string | null = null; | |
| let memoryCsrfToken: string | null = null; | ||
| let memorySessionOrigin: string | null = null; | ||
|
|
||
| /** | ||
| * Cookie-session arm, fired once at install when no memory credential exists. A returning | ||
| * visitor with a live `opencodex_gui_session` cookie authenticates through the cookie alone; | ||
| * this probe harvests the csrf/origin pair that /api requests must carry alongside it. Any | ||
| * failure (401, bad shape, foreign origin) is a silent no-op — the header paths still work. | ||
| */ | ||
| let cookieSessionArm: Promise<void> | null = null; | ||
|
|
||
| function readToken(): string | null { | ||
| return memoryToken; | ||
| } | ||
|
|
@@ -111,6 +121,57 @@ function storeSession(token: string | null, csrfToken: string | null, origin: st | |
| return true; | ||
| } | ||
|
|
||
| function startCookieSessionArm(): void { | ||
| // Holder: lets the async body compare against the installed arm without a | ||
| // use-before-assignment on its own const initializer (TS2454). | ||
| const installed: { arm: Promise<void> | null } = { arm: null }; | ||
| installed.arm = (async () => { | ||
| if (!rawFetch) return; | ||
| const bounded = createBoundedFetch(rebootstrapTimeoutMs); | ||
| try { | ||
| const response = await rawFetch(AUTH_SESSION_PATH, { cache: "no-store", signal: bounded.signal }); | ||
| if (!response.ok) return; | ||
| const session = await response.json().catch(() => null) as { csrfToken?: unknown; origin?: unknown } | null; | ||
| if (!session | ||
| || typeof session.csrfToken !== "string" | ||
| || !session.csrfToken | ||
| || session.origin !== window.location.origin) return; | ||
| // Superseded by a reset/reinstall (tests) or by a memory credential that landed meanwhile. | ||
| if (cookieSessionArm !== installed.arm || readToken() !== null) return; | ||
| // memoryToken stays null on purpose: the HttpOnly cookie is the credential here. | ||
| memoryCsrfToken = session.csrfToken; | ||
| memorySessionOrigin = session.origin; | ||
| } catch { | ||
| /* best-effort */ | ||
| } finally { | ||
| bounded.clear(); | ||
| } | ||
| })(); | ||
| cookieSessionArm = installed.arm; | ||
| } | ||
|
|
||
| /** | ||
| * Mint the HttpOnly cookie alongside a manually entered admin token, so the next page load | ||
| * signs in silently. Best-effort: on failure the header credential still authenticates every | ||
| * request; the cookie is an enhancement, never a dependency. | ||
| */ | ||
| async function mintCookieSession(token: string): Promise<void> { | ||
| if (!rawFetch) return; | ||
| const bounded = createBoundedFetch(rebootstrapTimeoutMs); | ||
| try { | ||
| await rawFetch(AUTH_SESSION_PATH, { | ||
| method: "POST", | ||
| cache: "no-store", | ||
| signal: bounded.signal, | ||
| headers: { "X-OpenCodex-API-Key": token }, | ||
| }); | ||
| } catch { | ||
| /* best-effort */ | ||
| } finally { | ||
| bounded.clear(); | ||
| } | ||
| } | ||
|
|
||
| /** Read one named meta tag out of a served HTML document (attribute order varies). */ | ||
| function metaContentFromHtml(html: string, name: string): string | null { | ||
| for (const tag of html.match(/<meta\b[^>]*>/gi) ?? []) { | ||
|
|
@@ -185,10 +246,14 @@ function clearLegacySessionToken(): void { | |
| } | ||
| } | ||
|
|
||
| function withToken(input: RequestInfo | URL, init: RequestInit | undefined, token: string): [RequestInfo | URL, RequestInit | undefined] { | ||
| function withToken(input: RequestInfo | URL, init: RequestInit | undefined, token: string | null): [RequestInfo | URL, RequestInit | undefined] { | ||
| // Nothing to attach (no credential, no cookie-session pair) — pass through untouched. | ||
| if (!token && !(memorySessionOrigin && memoryCsrfToken)) return [input, init]; | ||
| const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); | ||
| headers.set("X-OpenCodex-API-Key", token); | ||
| if (memorySessionOrigin && memoryCsrfToken && token.startsWith("ocx_session_")) { | ||
| if (token) headers.set("X-OpenCodex-API-Key", token); | ||
| // Cookie-backed sessions carry the GUI binding headers with any credential shape; the | ||
| // server ignores them on the raw-admin-token header path. | ||
| if (memorySessionOrigin && memoryCsrfToken) { | ||
| headers.set("X-OpenCodex-GUI-Origin", memorySessionOrigin); | ||
| const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); | ||
| if (method !== "GET" && method !== "HEAD") { | ||
|
|
@@ -234,6 +299,8 @@ async function resolveTokenAfter401(failedToken: string | null, callerSignal?: A | |
| const prompted = await requestAdminToken(verifyAdminToken); | ||
| if (prompted) { | ||
| storeToken(prompted); | ||
| // Bounded and fast; awaiting keeps the retry wave ordered after the mint. | ||
| await mintCookieSession(prompted); | ||
| return prompted; | ||
| } | ||
| promptCancelled = true; | ||
|
|
@@ -270,12 +337,35 @@ export function installApiAuthFetch(): void { | |
| loadInjectedSession(); | ||
| const originalFetch = window.fetch.bind(window); | ||
| rawFetch = originalFetch; | ||
| // No injected meta session (non-loopback or cookie-only visitor): probe the HttpOnly cookie | ||
| // session so the first /api wave carries origin/CSRF instead of 401-ing into a spurious prompt. | ||
| if (readToken() === null) startCookieSessionArm(); | ||
| window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { | ||
| if (!needsApiAuth(input)) return originalFetch(input, init); | ||
|
|
||
| const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined); | ||
| // While credential-less, wait once for the cookie arm. Racing it would 401 the first wave | ||
| // and pop a spurious admin-token prompt. The promise is settle-once; awaiting it again is free. | ||
| if (!callerSignal?.aborted && readToken() === null && cookieSessionArm) { | ||
| // An aborted caller must not sit out the arm probe's full timeout: race the signal so | ||
| // this fetch unwinds immediately while other callers keep waiting for the shared arm. | ||
| if (callerSignal) { | ||
| let onAbort: (() => void) | undefined; | ||
| await Promise.race([ | ||
| cookieSessionArm, | ||
| new Promise<void>((resolve) => { | ||
| onAbort = () => resolve(); | ||
| callerSignal.addEventListener("abort", onAbort, { once: true }); | ||
| }), | ||
| ]).finally(() => { | ||
| if (onAbort) callerSignal.removeEventListener("abort", onAbort); | ||
| }); | ||
| } else { | ||
| await cookieSessionArm; | ||
| } | ||
| } | ||
| const token = readToken(); | ||
|
Comment on lines
346
to
367
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Preserve caller aborts while the cookie-session probe is pending. Line 349 waits for 🤖 Prompt for AI Agents |
||
| const [firstInput, firstInit] = token ? withToken(input, init, token) : [input, init]; | ||
| const [firstInput, firstInit] = withToken(input, init, token); | ||
| const response = await originalFetch(firstInput, firstInit); | ||
| if (response.status !== 401) return response; | ||
|
|
||
|
|
@@ -306,6 +396,7 @@ export function resetApiAuthFetchForTests(adminTokenPrompt: AdminTokenPrompt = p | |
| memoryToken = null; | ||
| memoryCsrfToken = null; | ||
| memorySessionOrigin = null; | ||
| cookieSessionArm = null; | ||
| resolutionInFlight = null; | ||
| rawFetch = null; | ||
| promptCancelled = false; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Both pages claim the dashboard listener serves
/opencodex-session; that depends on an unverified loopback check. The route allowlist admits the path, butsrc/server/index.tsline 1598 serves the bootstrap only whenissueGuiSession(req, config, managementAuth)at line 1593 returns a session, and it is called with the shared loopbackconfigrather thandashboardPolicy(). IfissueGuiSessionrefuses non-loopback requests, the dashboard listener never serves that path and both sentences overstate the surface. If it does not refuse, the served page would carry a session token for a remote visitor, which is the security question raised onsrc/server/index.tslines 865-881.docs-site/src/content/docs/guides/web-dashboard.md#L41-L42: remove "the session bootstrap path/opencodex-session" from the served-surface list, or state the loopback condition under which it is served.docs-site/src/content/docs/reference/configuration/server.md#L116-L119: apply the same correction to the identical sentence.Resolve the
issueGuiSessionbehavior first, then align both sentences with it.As per path instructions: "Check that user-facing docs stay in sync with actual CLI/API behavior."
📍 Affects 2 files
docs-site/src/content/docs/guides/web-dashboard.md#L41-L42(this comment)docs-site/src/content/docs/reference/configuration/server.md#L116-L119🤖 Prompt for AI Agents
Source: Path instructions