From 7880b98909767d64feb1b2ec7c0bfd8e2efde9a6 Mon Sep 17 00:00:00 2001 From: x3M3x Date: Thu, 15 Oct 2026 13:49:19 +0400 Subject: [PATCH 01/10] test: reproduce missing persistent dashboard session exchange (5 RED) --- scripts/test-layout/layout.json | 1 + tests/fixtures/test-layout-expected.json | 1 + .../persistent-dashboard-session.test.ts | 82 +++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 tests/server/persistent-dashboard-session.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 16227fe5cf1..4d2522a87ba 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1598,6 +1598,7 @@ "server-live.test.ts": "server", "server-loopback-host-gate.test.ts": "server", "server-management-auth.test.ts": "server", "server-management-log-admission.test.ts": "server", + "persistent-dashboard-session.test.ts": "server", "server-opencode-go-goal-streaming.test.ts": "server", "server-rate-limit-retry-e2e.test.ts": "server", "server-request-body-size.test.ts": "server", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 210221f4d50..18b1829dd7a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1048,6 +1048,7 @@ "server-live-frame-log.test.ts": "server", "server-live-realtime-fixtures.test.ts": "server", "server-live.test.ts": "server", "server-loopback-host-gate.test.ts": "server", "server-management-auth.test.ts": "server", + "persistent-dashboard-session.test.ts": "server", "server-management-log-admission.test.ts": "server", "server-opencode-go-goal-streaming.test.ts": "server", "server-rate-limit-retry-e2e.test.ts": "server", "server-request-body-size.test.ts": "server", "server-search.test.ts": "server", diff --git a/tests/server/persistent-dashboard-session.test.ts b/tests/server/persistent-dashboard-session.test.ts new file mode 100644 index 00000000000..3925136b429 --- /dev/null +++ b/tests/server/persistent-dashboard-session.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { initializeManagementAuthState, createManagementSessionControl, issueGuiSession, requireManagementAuth } from "../../src/server/management-auth"; +import { handleSessionRoutes } from "../../src/server/management/session-routes"; +import type { ManagementContext } from "../../src/server/management/context"; +import { remoteConfig } from "../helpers/management-auth-fixture"; + +const previousHome = process.env.OPENCODEX_HOME; +const previousToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; +let home: string; +const config = remoteConfig(); +const origin = "https://dashboard.example.test"; +function request(path = "/api/session/remember", cookie?: string, overrides: HeadersInit = {}, method = "POST") { + return new Request(origin + path, { method, headers: { Host: "dashboard.example.test", Origin: origin, + "User-Agent": "Safari-PWA-fixture", "X-OpenCodex-API-Key": "ocx_admin_fixture", ...(cookie ? { Cookie: cookie } : {}), ...overrides } }); +} +function login() { + const state = initializeManagementAuthState(config); + const req = request(); + const response = handleSessionRoutes({ req, url: new URL(req.url), config, principal: "admin-token", + sessionControl: createManagementSessionControl(state) } as ManagementContext); + return { state, response }; +} +beforeEach(() => { home = mkdtempSync(join(tmpdir(), "ocx-persistent-dashboard-")); process.env.OPENCODEX_HOME = home; process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "ocx_admin_fixture"; }); +afterEach(() => { process.env.OPENCODEX_HOME = previousHome; process.env.OPENCODEX_ADMIN_AUTH_TOKEN = previousToken; rmSync(home, { recursive: true, force: true }); }); + +test("remember exchanges the admin token for a device cookie and a server-issued session", async () => { + const { response } = login(); + expect(response?.status).toBe(200); + const cookie = response!.headers.get("set-cookie")!; + expect(cookie).toContain("HttpOnly"); expect(cookie).toContain("Secure"); expect(cookie).toContain("SameSite=Strict"); expect(cookie).toContain("Path=/"); expect(cookie).not.toContain("Domain="); expect(cookie).not.toContain("ocx_admin_fixture"); + const body = await response!.json(); + expect(body.session.token).toStartWith("ocx_session_"); + expect(body.session.issuance).toBe("remembered-admin"); + const disk = readFileSync(join(home, "dashboard-sessions.json"), "utf8"); + expect(disk).not.toContain("ocx_admin_fixture"); + expect(disk).not.toContain(cookie.split(";")[0]!.split("=")[1]!); +}); + +test("a device resumes after a server restart, but not on another origin or browser", () => { + const { response } = login(); expect(response?.status).toBe(200); + const cookie = response!.headers.get("set-cookie")!.split(";")[0]!; + const restarted = initializeManagementAuthState(config); + const resumed = issueGuiSession(request("/opencodex-session", cookie, {}, "GET"), config, restarted); + expect(resumed?.token).toStartWith("ocx_session_"); + expect(issueGuiSession(request("/", cookie, { "User-Agent": "another-device" }, "GET"), config, restarted)).toBeNull(); + const other = new Request("https://other.example.test/", { headers: { Host: "other.example.test", Cookie: cookie, "User-Agent": "Safari-PWA-fixture" } }); + expect(issueGuiSession(other, config, restarted)).toBeNull(); +}); + +test("expiry is absolute and rotating the admin token invalidates persistent devices", () => { + const { response } = login(); expect(response?.status).toBe(200); + const cookie = response!.headers.get("set-cookie")!.split(";")[0]!; + const req = request("/", cookie, {}, "GET"); + expect(issueGuiSession(req, config, initializeManagementAuthState(config), { trustedTailscaleIngress: false, now: Date.now() + 31 * 86400_000 })).toBeNull(); + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "ocx_admin_rotated"; + expect(issueGuiSession(req, config, initializeManagementAuthState(config))).toBeNull(); +}); + +test("logout durably forgets this device and revokes all resumed sessions", async () => { + const { state, response } = login(); expect(response?.status).toBe(200); + const cookie = response!.headers.get("set-cookie")!.split(";")[0]!; + const resumed = issueGuiSession(request("/", cookie, {}, "GET"), config, state)!; + const session = (await response!.json()).session; + const req = request("/api/session/logout", cookie, { "X-OpenCodex-API-Key": session.token, "X-OpenCodex-GUI-Origin": origin, "X-OpenCodex-CSRF-Token": session.csrfToken }); + const result = handleSessionRoutes({ req, url: new URL(req.url), config, principal: "gui-session", sessionControl: createManagementSessionControl(state) } as ManagementContext); + expect(result?.status).toBe(200); expect(result!.headers.get("set-cookie")).toContain("Max-Age=0"); + expect(issueGuiSession(request("/", cookie, {}, "GET"), config, initializeManagementAuthState(config))).toBeNull(); + const replay = request("/api/config", cookie, { "X-OpenCodex-API-Key": resumed.token, "X-OpenCodex-GUI-Origin": origin }, "GET"); + expect(requireManagementAuth(replay, state, config)?.status).toBe(401); +}); + +test("remember rejects cross-origin, plaintext remote and unauthenticated issuance", () => { + const state = initializeManagementAuthState(config); const control = createManagementSessionControl(state); + for (const req of [request(undefined, undefined, { Origin: "https://attacker.test" }), new Request("http://dashboard.example.test/api/session/remember", { method: "POST", headers: { Host: "dashboard.example.test", Origin: "http://dashboard.example.test" } })]) { + const result = handleSessionRoutes({ req, url: new URL(req.url), config, principal: "admin-token", sessionControl: control } as ManagementContext); + expect(result?.status).toBe(403); + } + const req = request(); expect(handleSessionRoutes({ req, url: new URL(req.url), config, principal: "gui-session", sessionControl: control } as ManagementContext)?.status).toBe(403); +}); From 457c35464b4a330f102b4c7ef7a01c8a33b0d5d1 Mon Sep 17 00:00:00 2001 From: x3M3x Date: Thu, 15 Oct 2026 13:52:02 +0400 Subject: [PATCH 02/10] feat(auth): issue revocable HttpOnly device sessions (5 GREEN) --- src/server/auth-cors.ts | 1 + src/server/gui-session.ts | 7 +- src/server/management-auth.ts | 14 ++- src/server/management/session-routes.ts | 22 +++- src/server/persistent-gui-session.ts | 143 ++++++++++++++++++++++++ 5 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 src/server/persistent-gui-session.ts diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 71f7d43d436..a986db0e8a0 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -50,6 +50,7 @@ let _corsOrigin = "http://localhost:10100"; // Listener identity is process-local. Never infer this trust from forwarded headers: the // request handler marks a Request only after resolving the actual Bun Server instance. const trustedHubManagementRequests = new WeakSet(); +export function isTrustedHubManagementRequest(req: Request): boolean { return trustedHubManagementRequests.has(req); } export function markTrustedHubManagementRequest(req: Request): void { trustedHubManagementRequests.add(req); diff --git a/src/server/gui-session.ts b/src/server/gui-session.ts index daaf5a865f3..581a402c508 100644 --- a/src/server/gui-session.ts +++ b/src/server/gui-session.ts @@ -11,10 +11,13 @@ import { export type GuiSessionIssuance = | "loopback" + | "remembered-admin" | "tailscale-identity" | "pairing"; export interface GuiSessionRecord { + persistentDigest?: string; + persistentExpiresAt?: number; serverOrigin: string; browserOrigin: string; csrfToken: string; @@ -128,7 +131,7 @@ function evictOldestSession(state: GuiSessionState): void { } } -function mintSession( +export function mintSession( serverOrigin: string, browserOrigin: string, issuance: GuiSessionIssuance, @@ -457,6 +460,6 @@ export function authorizeGuiSessionRequest( const csrf = req.headers.get("x-opencodex-csrf-token")?.trim(); if (!csrf || !equalSecret(csrf, session.csrfToken)) return { ok: false, reason: "csrf" }; } - if (session.issuance !== "loopback" && !session.localPairing) session.expiresAt = now + REMOTE_GUI_SESSION_TTL_MS; + if (session.issuance !== "loopback" && !session.localPairing) session.expiresAt = Math.min(now + REMOTE_GUI_SESSION_TTL_MS, session.persistentExpiresAt ?? Infinity); return { ok: true, principal: "gui-session", session }; } diff --git a/src/server/management-auth.ts b/src/server/management-auth.ts index 19104ef1bf8..0c9ed366e3f 100644 --- a/src/server/management-auth.ts +++ b/src/server/management-auth.ts @@ -72,6 +72,7 @@ import { type GuiSessionRequestContext, } from "./gui-session"; import { hasLocalDesktopSnapshotCapability } from "./local-desktop-snapshot-auth"; +import { forgetGuiDevice, rememberGuiDevice, rememberedGuiSessionIsCurrent, resumeGuiDevice } from "./persistent-gui-session"; import { hasLocalAccountSwitchCapability } from "./local-account-switch-auth"; export type { GuiSessionBootstrap, GuiSessionRequestContext } from "./gui-session"; @@ -259,7 +260,7 @@ export function issueGuiSession( context?: GuiSessionRequestContext, ): GuiSessionBootstrap | null { if (!state.available) return null; - return issueGuiSessionFromState(req, config, state, context); + return resumeGuiDevice(req, config, state, context?.now) ?? issueGuiSessionFromState(req, config, state, context); } /** Returns the recorded issuance for the session that authorized this request. */ @@ -274,6 +275,8 @@ export function managementSessionIssuance( } export interface ManagementSessionControl { + rememberDevice?(req: Request, config: OcxConfig): ReturnType; + forgetDevice?(req: Request, config: OcxConfig): string | null; revokeCurrent(req: Request): boolean; /** Revalidate a long-lived request against current authority, without cached admission or renewal. */ isCurrent(req: Request, config: OcxConfig): boolean; @@ -290,7 +293,7 @@ export function createManagementSessionControl(state: ManagementAuthState): Mana const credential = requestManagementCredential(req); if (!credential || equalSecret(credential, adminToken)) return null; const session = state.sessions.get(credential); - if (!session) return null; + if (!session || !rememberedGuiSessionIsCurrent(req, config, state, session)) return null; // Reuse the full origin/expiry/CSRF predicate against the current record, but // isolate its sliding-expiry mutation: authority checks are not browser activity. return authorizeGuiSessionRequest(req, config, { @@ -299,6 +302,8 @@ export function createManagementSessionControl(state: ManagementAuthState): Mana }).ok ? session : null; } return { + rememberDevice(req, config) { return state.available ? rememberGuiDevice(req, config, state) : null; }, + forgetDevice(req, config) { return state.available ? forgetGuiDevice(req, config, state) : null; }, isCurrent(req: Request, config: OcxConfig): boolean { if (!state.available) return false; const credential = requestManagementCredential(req); @@ -581,7 +586,10 @@ function resolveManagementAdmission( else if (actual && isOpencodeCatalogRequest(req) && equalSecret(actual, opencodeCatalogToken(state.token))) { principal = "opencode-catalog-token"; } - else if (config && authorizeGuiSessionRequest(req, config, state).ok) principal = "gui-session"; + else if (config) { + const admission = authorizeGuiSessionRequest(req, config, state); + if (admission.ok && rememberedGuiSessionIsCurrent(req, config, state, admission.session)) principal = "gui-session"; + } } if (principal) admittedManagementRequests.set(req, principal); return principal; diff --git a/src/server/management/session-routes.ts b/src/server/management/session-routes.ts index dd3fcd7ff5e..22e493fe016 100644 --- a/src/server/management/session-routes.ts +++ b/src/server/management/session-routes.ts @@ -2,12 +2,30 @@ import { jsonResponse } from "../auth-cors"; import type { ManagementContext } from "./context"; export function handleSessionRoutes(ctx: ManagementContext): Response | null { + if (ctx.url.pathname === "/api/session/remember" && ctx.req.method === "POST") { + if (ctx.principal !== "admin-token") return jsonResponse({ error: "Admin token required" }, 403, ctx.req, ctx.config); + try { + const remembered = ctx.sessionControl?.rememberDevice?.(ctx.req, ctx.config); + if (!remembered) return jsonResponse({ error: "Remember requires a direct HTTPS dashboard (or loopback) and matching Origin" }, 403, ctx.req, ctx.config); + const response = jsonResponse({ session: remembered.session }, 200, ctx.req, ctx.config); + response.headers.set("Set-Cookie", remembered.cookie); + response.headers.set("Cache-Control", "no-store"); + return response; + } catch { return jsonResponse({ error: "Could not persist dashboard session" }, 503, ctx.req, ctx.config); } + } if (ctx.url.pathname !== "/api/session/logout" || ctx.req.method !== "POST") return null; if (ctx.principal !== "gui-session") { return jsonResponse({ error: "GUI session required" }, 403, ctx.req, ctx.config); } - if (!ctx.sessionControl?.revokeCurrent(ctx.req)) { + let expiredCookie: string | null | undefined; + try { expiredCookie = ctx.sessionControl?.forgetDevice?.(ctx.req, ctx.config); } + catch { return jsonResponse({ error: "Could not forget dashboard session" }, 503, ctx.req, ctx.config); } + // Forget may already have revoked every short-lived session belonging to this device. + if (!ctx.sessionControl?.revokeCurrent(ctx.req) && !expiredCookie) { return jsonResponse({ error: "GUI session not found" }, 401, ctx.req, ctx.config); } - return jsonResponse({ ok: true }, 200, ctx.req, ctx.config); + const response = jsonResponse({ ok: true }, 200, ctx.req, ctx.config); + if (expiredCookie) response.headers.set("Set-Cookie", expiredCookie); + response.headers.set("Cache-Control", "no-store"); + return response; } diff --git a/src/server/persistent-gui-session.ts b/src/server/persistent-gui-session.ts new file mode 100644 index 00000000000..4c5c42215a5 --- /dev/null +++ b/src/server/persistent-gui-session.ts @@ -0,0 +1,143 @@ +import { createHash, randomBytes } from "node:crypto"; +import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir } from "../config/paths"; +import { withConfigMutationLockSync } from "../config/mutation-lock"; +import { atomicWriteFileNoFollow } from "../config/atomic-write"; +import type { OcxConfig } from "../types"; +import { isLoopbackHostname, isTrustedHubManagementRequest, managementRequestOrigin } from "./auth-cors"; +import { mintSession, type GuiSessionRecord, type GuiSessionState } from "./gui-session"; + +export const REMEMBERED_GUI_TTL_MS = 30 * 86400_000; +const RECORD_LIMIT = 128; +const STORE_LIMIT = 128 * 1024; +type State = GuiSessionState & { token: string }; +interface DeviceRecord { + digest: string; + credential: string; + serverOrigin: string; + userAgent: string; + expiresAt: number; +} +const hash = (value: string) => createHash("sha256").update(value).digest("hex"); +const storePath = () => join(getConfigDir(), "dashboard-sessions.json"); + +function directOrigin(req: Request, config: OcxConfig): string | null { + const origin = managementRequestOrigin(req, config); + if (!origin) return null; + const url = new URL(origin); + if (new URL(req.url).origin !== origin && !isTrustedHubManagementRequest(req)) return null; + if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) return null; + const browser = req.headers.get("Origin"); + if (browser !== null && browser !== origin) return null; + const site = req.headers.get("Sec-Fetch-Site"); + return site && site !== "same-origin" && site !== "none" ? null : origin; +} + +function cookieName(origin: string): string { + return (origin.startsWith("https:") ? "__Host-" : "") + "ocx_dashboard_" + hash(origin).slice(0, 16); +} + +function deviceDigest(req: Request, origin: string): string | null { + const name = cookieName(origin); + const matches = (req.headers.get("Cookie") ?? "").split(";").map(part => part.trim()).filter(part => part.startsWith(name + "=")); + if (matches.length !== 1) return null; + const token = matches[0]!.slice(name.length + 1); + return /^ocx_device_[A-Za-z0-9_-]{43}$/.test(token) ? hash(token) : null; +} + +function cookie(origin: string, token: string, maxAge: number): string { + return cookieName(origin) + "=" + token + "; Path=/; HttpOnly; SameSite=Strict; Max-Age=" + maxAge + + (origin.startsWith("https:") ? "; Secure" : ""); +} + +function readRecords(): DeviceRecord[] { + const path = storePath(); + let fd: number; + try { fd = openSync(path, constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK)); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; } + try { + const stat = fstatSync(fd); + const entry = lstatSync(path); + if (!stat.isFile() || stat.nlink !== 1 || stat.size > STORE_LIMIT || entry.isSymbolicLink() + || stat.dev !== entry.dev || stat.ino !== entry.ino) throw new Error("unsafe dashboard session store"); + const bytes = Buffer.alloc(STORE_LIMIT + 1); + let length = 0; + while (length < bytes.length) { const count = readSync(fd, bytes, length, bytes.length - length, null); if (!count) break; length += count; } + if (length > STORE_LIMIT) throw new Error("dashboard session store too large"); + const value: unknown = JSON.parse(bytes.subarray(0, length).toString("utf8")); + if (!Array.isArray(value) || value.length > RECORD_LIMIT) throw new Error("invalid dashboard session store"); + for (const row of value) { + if (!row || typeof row !== "object" || Object.keys(row).length !== 5 + || ![row.digest, row.credential, row.userAgent].every(v => typeof v === "string" && /^[a-f0-9]{64}$/.test(v)) + || typeof row.serverOrigin !== "string" || row.serverOrigin.length > 2048 + || !Number.isSafeInteger(row.expiresAt) || row.expiresAt <= 0) throw new Error("invalid dashboard session record"); + const url = new URL(row.serverOrigin); + if (url.origin !== row.serverOrigin || !["http:", "https:"].includes(url.protocol)) throw new Error("invalid dashboard session origin"); + } + return value as DeviceRecord[]; + } finally { closeSync(fd); } +} + +function matches(row: DeviceRecord, req: Request, origin: string, state: State, now: number): boolean { + return row.serverOrigin === origin && row.credential === hash(state.token) + && row.userAgent === hash(req.headers.get("User-Agent") ?? "") && row.expiresAt > now; +} + +function mintDeviceSession(row: DeviceRecord, state: State, now: number) { + const session = mintSession(row.serverOrigin, row.serverOrigin, "remembered-admin", state, now); + const record = state.sessions.get(session.token)!; + record.persistentDigest = row.digest; + record.persistentExpiresAt = row.expiresAt; + session.expiresAt = Math.min(session.expiresAt, row.expiresAt); + return session; +} + +export function rememberGuiDevice(req: Request, config: OcxConfig, state: State, now = Date.now()) { + const origin = directOrigin(req, config); + if (req.method !== "POST" || !origin || req.headers.get("Origin") !== origin || req.body !== null) return null; + return withConfigMutationLockSync(() => { + const previous = deviceDigest(req, origin); + const rows = readRecords().filter(row => row.expiresAt > now && row.credential === hash(state.token)); + // Re-sign-in replaces only this device, never another server or browser's record. + const retained = rows.filter(row => !(row.digest === previous && matches(row, req, origin, state, now))); + if (retained.length >= RECORD_LIMIT) throw new Error("dashboard device limit reached"); + const token = "ocx_device_" + randomBytes(32).toString("base64url"); + const row: DeviceRecord = { digest: hash(token), credential: hash(state.token), serverOrigin: origin, + userAgent: hash(req.headers.get("User-Agent") ?? ""), expiresAt: now + REMEMBERED_GUI_TTL_MS }; + atomicWriteFileNoFollow(storePath(), JSON.stringify([...retained, row])); + return { session: mintDeviceSession(row, state, now), cookie: cookie(origin, token, REMEMBERED_GUI_TTL_MS / 1000) }; + }); +} + +export function resumeGuiDevice(req: Request, config: OcxConfig, state: State, now = Date.now()) { + const origin = directOrigin(req, config); + if (req.method !== "GET" || !origin) return null; + const digest = deviceDigest(req, origin); + if (!digest) return null; + try { + const row = readRecords().find(row => row.digest === digest && matches(row, req, origin, state, now)); + return row ? mintDeviceSession(row, state, now) : null; + } catch { return null; } +} + +export function rememberedGuiSessionIsCurrent(req: Request, config: OcxConfig, state: State, session: GuiSessionRecord): boolean { + if (!session.persistentDigest) return true; + const origin = directOrigin(req, config); + if (!origin || deviceDigest(req, origin) !== session.persistentDigest) return false; + try { return readRecords().some(row => row.digest === session.persistentDigest && matches(row, req, origin, state, Date.now())); } + catch { return false; } +} + +export function forgetGuiDevice(req: Request, config: OcxConfig, state: State): string | null { + const origin = directOrigin(req, config); + if (!origin || req.method !== "POST") return null; + const digest = deviceDigest(req, origin); + if (digest) withConfigMutationLockSync(() => { + const rows = readRecords(); + const found = rows.find(row => row.digest === digest && matches(row, req, origin, state, Date.now())); + if (found) atomicWriteFileNoFollow(storePath(), JSON.stringify(rows.filter(row => row !== found))); + for (const [token, session] of state.sessions) if (session.persistentDigest === digest) state.sessions.delete(token); + }); + return cookie(origin, "", 0); +} From f94f7870a0582e60e6fd064136b7213635a3acf0 Mon Sep 17 00:00:00 2001 From: x3M3x Date: Thu, 15 Oct 2026 13:54:23 +0400 Subject: [PATCH 03/10] test: reproduce missing cookie-backed Remember UI (3 RED) --- gui/tests/persistent-dashboard.test.ts | 57 ++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 gui/tests/persistent-dashboard.test.ts diff --git a/gui/tests/persistent-dashboard.test.ts b/gui/tests/persistent-dashboard.test.ts new file mode 100644 index 00000000000..974c52ccc69 --- /dev/null +++ b/gui/tests/persistent-dashboard.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { promptForAdminToken } from "../src/admin-token-dialog"; +import { hasApiSession, installApiAuthFetch, resetApiAuthFetchForTests } from "../src/api"; + +const names = ["window", "document", "navigator", "localStorage", "sessionStorage", "HTMLElement"] as const; +let previous: unknown[]; +let browser: Window; +beforeEach(() => { + previous = names.map(name => Reflect.get(globalThis, name)); + browser = new Window({ url: "https://dashboard.example.test/" }); + for (const name of names) Object.defineProperty(globalThis, name, { configurable: true, value: Reflect.get(browser, name) }); + const meta = document.createElement("meta"); meta.name = "opencodex-management-auth-required"; meta.content = "1"; document.head.append(meta); + resetApiAuthFetchForTests(); +}); +afterEach(() => { resetApiAuthFetchForTests(); browser.close(); names.forEach((name, i) => Object.defineProperty(globalThis, name, { configurable: true, value: previous[i] })); }); + +test("remember is opt-in and asks the server to persist, never browser storage", async () => { + let requested: unknown; + const pending = promptForAdminToken(async (_token, remember) => { requested = remember; return "accepted"; }); + const box = document.querySelector('input[name="remember-device"]'); + expect(box).not.toBeNull(); expect(box!.checked).toBe(false); + box!.checked = true; + const form = document.querySelector("form")!; + form.querySelector('input[name="password"]')!.value = "ocx_admin_fixture"; + form.dispatchEvent(new browser.Event("submit", { cancelable: true })); + expect(await pending).toBe("ocx_admin_fixture"); expect(requested).toBe(true); + expect(localStorage.length).toBe(0); expect(sessionStorage.length).toBe(0); expect(document.cookie).toBe(""); +}); + +test("remember installs the returned GUI session instead of retrying with the admin token", async () => { + const seen: Array<{ path: string; headers: Headers; init?: RequestInit }> = []; + resetApiAuthFetchForTests(async (verify, allowed) => { expect(allowed).toBe(true); expect(await verify("ocx_admin_fixture", true)).toBe("accepted"); return "ocx_admin_fixture"; }); + window.fetch = (async (input, init) => { + const path = String(input); const headers = new Headers(init?.headers); seen.push({ path, headers, init }); + if (path.endsWith("/api/session/remember")) return Response.json({ session: { token: "ocx_session_remembered", csrfToken: "csrf-fixture", browserOrigin: window.location.origin, serverOrigin: window.location.origin } }); + if (path === "/api/config" && headers.get("X-OpenCodex-API-Key") === "ocx_session_remembered") return Response.json({ ok: true }); + return new Response(null, { status: 401 }); + }) as typeof fetch; + installApiAuthFetch(); + expect((await window.fetch("/api/config")).status).toBe(200); expect(hasApiSession("shared")).toBe(true); + const exchange = seen.find(row => row.path.endsWith("/api/session/remember"))!; + expect(exchange.init?.method).toBe("POST"); expect(exchange.init?.credentials).toBe("same-origin"); + expect(seen.filter(row => row.path === "/api/config").some(row => row.headers.get("X-OpenCodex-API-Key") === "ocx_admin_fixture")).toBe(false); +}); + +test("obsolete remembered bearer tokens are removed without reading or migrating them", () => { + localStorage.setItem("opencodex.remembered-admin-token", "unsafe-legacy"); + localStorage.setItem("opencodex.remembered-admin-token:https://server|same-origin", "unsafe-scoped"); + localStorage.setItem("opencodex.remembered-admin-token-decoy", "unrelated"); + const getter = localStorage.getItem.bind(localStorage); let reads = 0; + localStorage.getItem = key => { reads++; return getter(key); }; + installApiAuthFetch(); + expect(reads).toBe(0); expect(getter("opencodex.remembered-admin-token")).toBeNull(); + expect(getter("opencodex.remembered-admin-token:https://server|same-origin")).toBeNull(); + expect(getter("opencodex.remembered-admin-token-decoy")).toBe("unrelated"); +}); From a81561745e8d138219e83431f70d355ffc880dab Mon Sep 17 00:00:00 2001 From: x3M3x Date: Thu, 15 Oct 2026 13:56:44 +0400 Subject: [PATCH 04/10] feat(dashboard): remember a server-side session, not a bearer token (66 GREEN) --- gui/src/App.tsx | 23 ++++++----- gui/src/admin-token-dialog.ts | 22 +++++++++-- gui/src/api.ts | 51 +++++++++++++++++++------ gui/src/i18n/de.ts | 4 ++ gui/src/i18n/en.ts | 4 ++ gui/src/i18n/fr.ts | 4 ++ gui/src/i18n/ja.ts | 4 ++ gui/src/i18n/ko.ts | 4 ++ gui/src/i18n/pt.ts | 4 ++ gui/src/i18n/ru.ts | 4 ++ gui/src/i18n/tr.ts | 4 ++ gui/src/i18n/vi.ts | 4 ++ gui/src/i18n/zh-TW.ts | 4 ++ gui/src/i18n/zh.ts | 4 ++ src/server/management/route-registry.ts | 1 + 15 files changed, 116 insertions(+), 25 deletions(-) diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 99fa327d6b0..7876b14bb27 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -22,7 +22,7 @@ import { NAV_GROUPS, groupForPage, visibleGroupPages } from "./nav-groups"; import { SectionSwitcher } from "./components/SectionSwitcher"; import { useI18n, useT, LOCALES, localeDisplayName, type Locale, type TKey } from "./i18n/shared"; import { Notice, Select, ToastNotice, type NoticeTone } from "./ui"; -import { bootstrapApiSession, configureApiTargets, hasApiSession, installApiAuthFetch, logoutApiSession, SESSION_UNAVAILABLE_EVENT } from "./api"; +import { bootstrapApiSession, canRememberDashboardDevice, configureApiTargets, hasApiSession, installApiAuthFetch, logoutApiSession, SESSION_CHANGED_EVENT, SESSION_UNAVAILABLE_EVENT } from "./api"; import { apiBaseForPlane, discoverApiTargets, isConnectedRuntime, runtimeRoleFromDocument, standaloneApiTargets, type ApiTargets } from "./api-targets"; import { ConnectPairingForm } from "./connect-pairing"; import { validateRemoteLinkSession } from "./connect-pairing-transport"; @@ -159,12 +159,13 @@ export default function App() { useEffect(() => { const unavailable = (event: Event) => { - if ((event as CustomEvent<{ plane?: string }>).detail?.plane === "shared" && !hasApiSession("shared")) { - setSharedSessionReady(false); + if ((event as CustomEvent<{ plane?: string }>).detail?.plane === "shared") { + setSharedSessionReady(hasApiSession("shared")); } }; window.addEventListener(SESSION_UNAVAILABLE_EVENT, unavailable); - return () => window.removeEventListener(SESSION_UNAVAILABLE_EVENT, unavailable); + window.addEventListener(SESSION_CHANGED_EVENT, unavailable); + return () => { window.removeEventListener(SESSION_UNAVAILABLE_EVENT, unavailable); window.removeEventListener(SESSION_CHANGED_EVENT, unavailable); }; }, []); useEffect(() => { @@ -359,8 +360,10 @@ export default function App() { const loggedOut = await logoutApiSession("shared"); setSessionLoggingOut(false); if (loggedOut) setSharedSessionReady(false); - else report(t("connection.sessionLogoutFailed"), "err"); + else report(t(canRememberDashboardDevice("shared") ? "connection.forgetDeviceFailed" : "connection.sessionLogoutFailed"), "err"); }; + const deviceForgetAvailable = canRememberDashboardDevice("shared"); + const endSessionLabel = t(deviceForgetAvailable ? "connection.forgetDevice" : "connection.sessionLogout"); /* * The brand is the control users reach for first when they want out of a deep page, @@ -413,9 +416,9 @@ export default function App() { {brand}
- {targets.connected && sharedSessionReady && ( + {(targets.connected || deviceForgetAvailable) && sharedSessionReady && ( )} @@ -507,10 +510,10 @@ export default function App() {
{t("dash.actions")}
- {targets.connected && sharedSessionReady && ( + {(targets.connected || deviceForgetAvailable) && sharedSessionReady && ( )} diff --git a/gui/src/admin-token-dialog.ts b/gui/src/admin-token-dialog.ts index cb383fe7d63..539ca981b51 100644 --- a/gui/src/admin-token-dialog.ts +++ b/gui/src/admin-token-dialog.ts @@ -5,16 +5,17 @@ const ADMIN_TOKEN_USERNAME = "OpenCodex"; const ADMIN_TOKEN_DOCS_URL = "https://opencodex.me/guides/web-dashboard/#finding-the-admin-token"; export type AdminTokenValidation = "accepted" | "rejected" | "unavailable"; -export type AdminTokenVerifier = (token: string) => Promise; +export type AdminTokenVerifier = (token: string, rememberDevice?: boolean) => Promise; /** * Ask for the management credential with a real sign-in form so browsers and * password managers can offer save/autofill. OpenCodex itself still keeps the - * submitted token in memory only; persistence remains entirely browser-owned. + * submitted token in memory only; Remember requests an HttpOnly server session. */ export function promptForAdminToken( verifyToken: AdminTokenVerifier, locale: Locale = getActiveLocale(), + rememberAllowed = true, ): Promise { const messages = DICTS[locale]; const titleText = messages["auth.adminTokenTitle"]; @@ -91,6 +92,18 @@ export function promptForAdminToken( help.append(" ", docsLink); tokenField.append(help); + const rememberLabel = document.createElement("label"); + rememberLabel.className = "field-label"; + const remember = document.createElement("input"); + remember.type = "checkbox"; + remember.name = "remember-device"; + remember.disabled = !rememberAllowed; + rememberLabel.append(remember, " ", messages["auth.rememberDevice"]); + const rememberHint = document.createElement("p"); + rememberHint.className = "hint"; + rememberHint.textContent = messages["auth.rememberDeviceHint"]; + tokenField.append(rememberLabel, rememberHint); + const validationError = document.createElement("div"); validationError.className = "notice notice-err"; validationError.setAttribute("role", "alert"); @@ -142,9 +155,10 @@ export function promptForAdminToken( } password.disabled = true; submit.disabled = true; + remember.disabled = true; setValidationError(null); - void verifyToken(token).then((result) => { + void verifyToken(token, rememberAllowed && remember.checked).then((result) => { if (settled) return; if (result === "accepted") { finish(token); @@ -153,6 +167,7 @@ export function promptForAdminToken( password.value = ""; password.disabled = false; submit.disabled = false; + remember.disabled = !rememberAllowed; setValidationError(result === "rejected" ? messages["auth.adminTokenRejected"] : messages["auth.adminTokenUnavailable"]); @@ -162,6 +177,7 @@ export function promptForAdminToken( password.value = ""; password.disabled = false; submit.disabled = false; + remember.disabled = !rememberAllowed; setValidationError(messages["auth.adminTokenUnavailable"]); password.focus(); }); diff --git a/gui/src/api.ts b/gui/src/api.ts index eafa7866171..c259b486a34 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -41,13 +41,14 @@ interface SessionRevision { state: TargetRuntime; generation: object } interface SessionSnapshot extends SessionRevision { token: string | null } interface RecoveryAttempt extends SessionRevision { plane: ApiPlane; active: boolean } -type AdminTokenPrompt = (verifyToken: AdminTokenVerifier) => Promise; +type AdminTokenPrompt = (verifyToken: AdminTokenVerifier, rememberAllowed?: boolean) => Promise; +const defaultAdminTokenPrompt: AdminTokenPrompt = (verify, allowed) => promptForAdminToken(verify, undefined, allowed); type RebootstrapResult = { kind: "minted"; token: string } | { kind: "unavailable" } | { kind: "failed" }; let installed = false; let rawFetch: typeof fetch | null = null; let configuredTargets: ApiTargets | null = null; -let requestAdminToken: AdminTokenPrompt = promptForAdminToken; +let requestAdminToken: AdminTokenPrompt = defaultAdminTokenPrompt; let rebootstrapTimeoutMs = SESSION_REBOOTSTRAP_TIMEOUT_MS; let resolutionWatchdogMs = RESOLUTION_WATCHDOG_MS; const runtimes = new Map(); @@ -290,6 +291,19 @@ export function fetchApiSessionValidation(plane: ApiPlane, input: RequestInfo | function clearLegacySessionToken(): void { try { sessionStorage.removeItem(LEGACY_TOKEN_KEY); } catch { /* storage may be disabled */ } + try { + const storage = window.localStorage; + for (let i = storage.length - 1; i >= 0; i--) { + const key = storage.key(i); + if (key === "opencodex.remembered-admin-token" || key?.startsWith("opencodex.remembered-admin-token:")) storage.removeItem(key); + } + } catch { /* do not read or migrate old bearer tokens */ } +} + +export function canRememberDashboardDevice(plane: ApiPlane): boolean { + const target = runtime(plane).target; + return target.transport !== "relay" && target.serverOrigin === window.location.origin + && (window.location.protocol === "https:" || ["localhost", "127.0.0.1", "[::1]"].includes(window.location.hostname)); } function targetAbsoluteBase(target: ApiTarget): URL { @@ -381,14 +395,26 @@ export async function bootstrapApiSession(plane: ApiPlane, signal?: AbortSignal) finally { recovery.active = false; } } -async function verifyAdminToken(plane: ApiPlane, token: string): ReturnType { +async function verifyAdminToken(recovery: RecoveryAttempt, token: string, remember = false): ReturnType { if (!rawFetch) return "unavailable"; + const { plane, state } = recovery; + if (remember && !canRememberDashboardDevice(plane)) return "unavailable"; try { - const state = runtime(plane); - const [input, init] = withAuth(plane, `${state.target.baseUrl}${ADMIN_TOKEN_VALIDATION_PATH}`, { cache: "no-store" }, token); - const response = await rawFetch(input, init); - if (response.status === 401) return "rejected"; - return response.ok ? "accepted" : "unavailable"; + return await withFetchDeadline(rebootstrapTimeoutMs, undefined, async signal => { + const path = remember ? "/api/session/remember" : ADMIN_TOKEN_VALIDATION_PATH; + const [input, init] = withAuth(plane, state.target.baseUrl + path, + { method: remember ? "POST" : "GET", cache: "no-store", signal, credentials: remember ? "same-origin" : "omit", redirect: "error" }, token); + const response = await rawFetch!(input, init); + if (signal.aborted || !isCurrentRecovery(recovery)) return "unavailable"; + if (response.status === 401) return "rejected"; + if (!response.ok) return "unavailable"; + if (remember) { + const body = await response.json(); + if (signal.aborted || !isCurrentRecovery(recovery) || !storeSession(plane, body?.session?.token, + body?.session?.csrfToken, body?.session?.browserOrigin, body?.session?.serverOrigin, recovery)) return "unavailable"; + } + return "accepted"; + }); } catch { return "unavailable"; } } @@ -421,13 +447,14 @@ async function resolveTokenAfter401(plane: ApiPlane, failedToken: string | null, state.promptCancelled = true; return sessionSnapshot(state); } - const prompted = await requestAdminToken(async token => { + const prompted = await requestAdminToken(async (token, remember) => { if (!isCurrentRecovery(recovery)) return "unavailable"; - const result = await verifyAdminToken(plane, token); + const result = await verifyAdminToken(recovery, token, remember); return isCurrentRecovery(recovery) ? result : "unavailable"; - }); + }, canRememberDashboardDevice(plane)); if (!isCurrentRecovery(recovery)) return null; if (prompted) { + if (state.session.token?.startsWith("ocx_session_")) return sessionSnapshot(state); updateSession(state, { token: prompted, csrfToken: null, browserOrigin: null, serverOrigin: state.target.serverOrigin }, recovery); return isCurrentRecovery(recovery) ? sessionSnapshot(state) : null; } @@ -512,7 +539,7 @@ export function fetchAudioUpload(endpoint: string, init: RequestInit): Promise = { "auth.adminTokenRejected": "Der Admin-Token wurde abgelehnt. Prüfen Sie ihn und versuchen Sie es erneut.", "auth.adminTokenUnavailable": "Der Admin-Token konnte nicht überprüft werden. Versuchen Sie es erneut.", "auth.adminTokenHelp": "Dies ist der Admin-Token der OpenCodex-Verwaltungs-API, kein Anbieter-API-Schlüssel. Beim ersten Start schreibt der Proxy ihn nach ~/.opencodex/admin-api-token (oder $OPENCODEX_HOME/admin-api-token); OPENCODEX_ADMIN_AUTH_TOKEN hat Vorrang.", + "auth.rememberDevice": "Dieses Gerät merken", + "auth.rememberDeviceHint": "Eine widerrufbare HttpOnly-Dashboard-Sitzung 30 Tage behalten, nicht das Admin-Token. Erfordert ein direktes HTTPS-Dashboard (oder lokalen Loopback).", + "connection.forgetDevice": "Dieses Gerät vergessen", + "connection.forgetDeviceFailed": "Dieses Gerät konnte nicht vergessen werden. Die Sitzung wurde beibehalten.", "auth.adminTokenDocsLink": "So finden Sie ihn", "theme.label": "Design", "theme.light": "Hell", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index bff6fef8e56..3465a1763e9 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -187,6 +187,10 @@ export const en = { "auth.adminTokenRejected": "That admin token was rejected. Check it and try again.", "auth.adminTokenUnavailable": "The admin token could not be verified. Try again.", "auth.adminTokenHelp": "This is the OpenCodex management admin token, not a provider API key. The proxy writes it to ~/.opencodex/admin-api-token (or $OPENCODEX_HOME/admin-api-token) on first start, and OPENCODEX_ADMIN_AUTH_TOKEN overrides it.", + "auth.rememberDevice": "Remember this device", + "auth.rememberDeviceHint": "Keep a revocable HttpOnly dashboard session for 30 days, not the admin token. Requires a direct HTTPS dashboard (or local loopback).", + "connection.forgetDevice": "Forget this device", + "connection.forgetDeviceFailed": "Could not forget this device. The session was kept.", "auth.adminTokenDocsLink": "How to find it", "app.logoAria": "opencodex logo", "app.claudeOn": "Claude ON", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 5681f60e2d2..01f3916bba2 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -184,6 +184,10 @@ export const fr: Record = { "auth.adminTokenRejected": "Ce jeton d’administration a été refusé. Vérifiez-le et réessayez.", "auth.adminTokenUnavailable": "Le jeton d’administration n’a pas pu être vérifié. Réessayez.", "auth.adminTokenHelp": "Il s’agit du jeton d’administration de l’API de gestion OpenCodex, pas d’une clé API de fournisseur. Au premier démarrage, le proxy l’écrit dans ~/.opencodex/admin-api-token (ou $OPENCODEX_HOME/admin-api-token), et OPENCODEX_ADMIN_AUTH_TOKEN a la priorité.", + "auth.rememberDevice": "Mémoriser cet appareil", + "auth.rememberDeviceHint": "Conserve une session HttpOnly révocable pendant 30 jours, pas le jeton administrateur. Nécessite un tableau de bord HTTPS direct (ou une boucle locale).", + "connection.forgetDevice": "Oublier cet appareil", + "connection.forgetDeviceFailed": "Impossible d’oublier cet appareil. La session a été conservée.", "auth.adminTokenDocsLink": "Où le trouver", "app.logoAria": "Logo opencodex", "app.claudeOn": "Claude ACTIVÉ", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index ce6e00d9c7e..e5c3f36abb2 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -254,6 +254,10 @@ export const ja: Record = { "auth.adminTokenRejected": "管理者トークンが拒否されました。確認してもう一度お試しください。", "auth.adminTokenUnavailable": "管理者トークンを確認できませんでした。もう一度お試しください。", "auth.adminTokenHelp": "これは OpenCodex 管理 API の管理者トークンで、プロバイダーの API キーではありません。プロキシは初回起動時に ~/.opencodex/admin-api-token(または $OPENCODEX_HOME/admin-api-token)へ書き込み、OPENCODEX_ADMIN_AUTH_TOKEN を設定するとそちらが優先されます。", + "auth.rememberDevice": "このデバイスを記憶する", + "auth.rememberDeviceHint": "管理者トークンではなく、取り消し可能な HttpOnly セッションを30日間保持します。直接アクセスする HTTPS ダッシュボード(またはローカルループバック)が必要です。", + "connection.forgetDevice": "このデバイスを解除", + "connection.forgetDeviceFailed": "デバイスを解除できませんでした。セッションは保持されています。", "auth.adminTokenDocsLink": "確認方法", "app.logoAria": "opencodex ロゴ", "app.claudeOn": "Claude オン", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index c1e1e77d9f2..f8550084f6b 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -252,6 +252,10 @@ export const ko: Record = { "auth.adminTokenRejected": "관리자 토큰이 거부되었습니다. 확인한 후 다시 시도하세요.", "auth.adminTokenUnavailable": "관리자 토큰을 확인할 수 없습니다. 다시 시도하세요.", "auth.adminTokenHelp": "이 값은 OpenCodex 관리 API의 관리자 토큰이며 공급자 API 키가 아닙니다. 프록시가 처음 실행될 때 ~/.opencodex/admin-api-token(또는 $OPENCODEX_HOME/admin-api-token)에 기록하고, OPENCODEX_ADMIN_AUTH_TOKEN을 설정하면 그 값이 우선합니다.", + "auth.rememberDevice": "이 기기 기억하기", + "auth.rememberDeviceHint": "관리자 토큰이 아닌 취소 가능한 HttpOnly 세션을 30일 동안 유지합니다. 직접 연결된 HTTPS 대시보드(또는 로컬 루프백)가 필요합니다.", + "connection.forgetDevice": "이 기기 잊기", + "connection.forgetDeviceFailed": "이 기기를 해제하지 못했습니다. 세션은 유지됩니다.", "auth.adminTokenDocsLink": "찾는 방법", "theme.label": "테마", "theme.light": "라이트", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index cca8aaa6964..f36ef75e430 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -209,6 +209,10 @@ export const pt: Record = { "auth.adminTokenRejected": "Esse token de administrador foi rejeitado. Confira-o e tente novamente.", "auth.adminTokenUnavailable": "Não foi possível verificar o token de administrador. Tente novamente.", "auth.adminTokenHelp": "Este é o token de administrador de gerenciamento do OpenCodex, não uma chave de API de provedor. O proxy o grava em ~/.opencodex/admin-api-token (ou $OPENCODEX_HOME/admin-api-token) na primeira inicialização, e OPENCODEX_ADMIN_AUTH_TOKEN o substitui.", + "auth.rememberDevice": "Lembrar este dispositivo", + "auth.rememberDeviceHint": "Mantém uma sessão HttpOnly revogável por 30 dias, não o token de administrador. Requer um painel HTTPS direto (ou loopback local).", + "connection.forgetDevice": "Esquecer este dispositivo", + "connection.forgetDeviceFailed": "Não foi possível esquecer este dispositivo. A sessão foi mantida.", "auth.adminTokenDocsLink": "Como encontrá-lo", "app.logoAria": "logotipo do opencodex", "app.claudeOn": "Claude ON", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2709713c0bb..93bf4b01417 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -254,6 +254,10 @@ export const ru: Record = { "auth.adminTokenRejected": "Токен администратора отклонён. Проверьте его и повторите попытку.", "auth.adminTokenUnavailable": "Не удалось проверить токен администратора. Повторите попытку.", "auth.adminTokenHelp": "Это административный токен управляющего API OpenCodex, а не ключ API провайдера. При первом запуске прокси записывает его в ~/.opencodex/admin-api-token (или $OPENCODEX_HOME/admin-api-token), а OPENCODEX_ADMIN_AUTH_TOKEN переопределяет это значение.", + "auth.rememberDevice": "Запомнить это устройство", + "auth.rememberDeviceHint": "Сохраняет отзываемую HttpOnly-сессию на 30 дней, а не токен администратора. Требуется прямой доступ к панели по HTTPS (или локальный loopback).", + "connection.forgetDevice": "Забыть это устройство", + "connection.forgetDeviceFailed": "Не удалось забыть это устройство. Сессия сохранена.", "auth.adminTokenDocsLink": "Как его найти", "app.logoAria": "Логотип opencodex", "app.claudeOn": "Claude ВКЛ", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 154d0356c53..50aa22626a7 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -186,6 +186,10 @@ export const tr: Record = { "auth.adminTokenRejected": "Bu yönetici jetonu reddedildi. Kontrol edip tekrar deneyin.", "auth.adminTokenUnavailable": "Yönetici jetonu doğrulanamadı. Tekrar deneyin.", "auth.adminTokenHelp": "Bu, sağlayıcı API anahtarı değil, OpenCodex yönetim API’sinin yönetici jetonudur. Proxy ilk açılışta bunu ~/.opencodex/admin-api-token (veya $OPENCODEX_HOME/admin-api-token) dosyasına yazar; OPENCODEX_ADMIN_AUTH_TOKEN bu değeri geçersiz kılar.", + "auth.rememberDevice": "Bu cihazı hatırla", + "auth.rememberDeviceHint": "Yönetici belirteci yerine iptal edilebilir HttpOnly oturumunu 30 gün tutar. Doğrudan HTTPS paneli (veya yerel loopback) gerekir.", + "connection.forgetDevice": "Bu cihazı unut", + "connection.forgetDeviceFailed": "Bu cihaz unutulamadı. Oturum korundu.", "auth.adminTokenDocsLink": "Nasıl bulunur", "app.logoAria": "opencodex logosu", "app.claudeOn": "Claude AÇIK", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index a0b33ac014d..9a5a1e6d84a 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -184,6 +184,10 @@ export const vi: Record = { "auth.adminTokenRejected": "Token quản trị đó đã bị từ chối. Hãy kiểm tra lại và thử lại.", "auth.adminTokenUnavailable": "Không thể xác minh token quản trị. Hãy thử lại.", "auth.adminTokenHelp": "Đây là token quản trị quản lý OpenCodex, không phải là API key của provider. Proxy ghi nó vào ~/.opencodex/admin-api-token (hoặc $OPENCODEX_HOME/admin-api-token) trong lần khởi động đầu tiên và OPENCODEX_ADMIN_AUTH_TOKEN sẽ ghi đè nó.", + "auth.rememberDevice": "Ghi nhớ thiết bị này", + "auth.rememberDeviceHint": "Giữ phiên HttpOnly có thể thu hồi trong 30 ngày, không lưu mã quản trị. Yêu cầu bảng điều khiển HTTPS trực tiếp (hoặc loopback cục bộ).", + "connection.forgetDevice": "Quên thiết bị này", + "connection.forgetDeviceFailed": "Không thể quên thiết bị này. Phiên vẫn được giữ.", "auth.adminTokenDocsLink": "Cách tìm nó", "app.logoAria": "logo opencodex", "app.claudeOn": "Claude BẬT", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index f2378eaedfd..ba4fb2e6145 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2791,6 +2791,10 @@ export const zhTW: Record = { "auth.adminTokenRejected": "該管理員金鑰被拒絕。請檢查後再試一次。", "auth.adminTokenUnavailable": "無法驗證管理員金鑰。請再試一次。", "auth.adminTokenHelp": "這是 OpenCodex 管理 API 的管理員金鑰,不是服務商 API 金鑰。代理首次啟動時會寫入 ~/.opencodex/admin-api-token(或 $OPENCODEX_HOME/admin-api-token),設定 OPENCODEX_ADMIN_AUTH_TOKEN 可覆寫該值。", + "auth.rememberDevice": "記住此裝置", + "auth.rememberDeviceHint": "保留可撤銷的 HttpOnly 工作階段30天,不儲存管理員權杖。需要直接存取 HTTPS 儀表板(或本機回環)。", + "connection.forgetDevice": "忘記此裝置", + "connection.forgetDeviceFailed": "無法忘記此裝置。工作階段已保留。", "auth.adminTokenDocsLink": "如何尋找", "lang.nativeName": "繁體中文", "provider.name.commandCodeAuth": "Command Code - Auth", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index cf32dcc5a88..9e47d24acbf 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -252,6 +252,10 @@ export const zh: Record = { "auth.adminTokenRejected": "管理员令牌被拒绝。请检查后重试。", "auth.adminTokenUnavailable": "无法验证管理员令牌。请重试。", "auth.adminTokenHelp": "这是 OpenCodex 管理 API 的管理员令牌,不是服务商 API 密钥。代理首次启动时会写入 ~/.opencodex/admin-api-token(或 $OPENCODEX_HOME/admin-api-token),设置 OPENCODEX_ADMIN_AUTH_TOKEN 可覆盖该值。", + "auth.rememberDevice": "记住此设备", + "auth.rememberDeviceHint": "保留可撤销的 HttpOnly 会话30天,不保存管理员令牌。需要直接访问 HTTPS 仪表板(或本地回环)。", + "connection.forgetDevice": "忘记此设备", + "connection.forgetDeviceFailed": "无法忘记此设备。会话已保留。", "auth.adminTokenDocsLink": "如何查找", "theme.label": "主题", "theme.light": "浅色", diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 3a24f7138fe..6f62598cc19 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -384,6 +384,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "POST", path: "/api/routing-profiles/dry-run", module: "server/management/routing-profile-routes", mutates: true }, { method: "PUT", path: "/api/routing-profiles", module: "server/management/routing-profile-routes", mutates: true }, // server/management/session-routes + { method: "POST", path: "/api/session/remember", module: "server/management/session-routes", mutates: true, exempt: { reason: "session-only", why: "Explicit admin-token exchange for a revocable HttpOnly device cookie, restricted to a same-origin HTTPS dashboard or loopback. The CLI does not own a browser cookie jar." } }, { method: "POST", path: "/api/session/logout", module: "server/management/session-routes", mutates: true, exempt: { reason: "session-only", why: "Logs out the CURRENT gui-session and requires its own Origin and CSRF. There is nothing for a CLI verb to log out of: the CLI holds an admin token, and the admin token is refused here precisely so it cannot end a consent session it never established." } }, // server/management/sidebar-routes { method: "GET", path: "/api/github/star", module: "server/management/sidebar-routes", mutates: false }, From c4a9bf54e8e938d7de8c37405c897534fb5024ae Mon Sep 17 00:00:00 2001 From: x3M3x Date: Thu, 15 Oct 2026 14:20:03 +0400 Subject: [PATCH 05/10] docs(auth): document device sessions and cover refusal boundaries (11 GREEN) --- .../content/docs/fr/guides/web-dashboard.md | 2 ++ .../src/content/docs/guides/web-dashboard.md | 2 ++ .../content/docs/ja/guides/web-dashboard.md | 2 ++ .../content/docs/ko/guides/web-dashboard.md | 2 ++ .../content/docs/ru/guides/web-dashboard.md | 2 ++ .../content/docs/tr/guides/web-dashboard.md | 2 ++ .../docs/zh-cn/guides/web-dashboard.md | 2 ++ .../docs/zh-tw/guides/web-dashboard.md | 2 ++ gui/tests/persistent-dashboard.test.ts | 18 ++++++++++++++- structure/dashboard-and-usage.md | 2 +- structure/gui-and-management-api.md | 2 +- .../persistent-dashboard-session.test.ts | 23 ++++++++++++++++++- 12 files changed, 57 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/web-dashboard.md b/docs-site/src/content/docs/fr/guides/web-dashboard.md index 1777b9be854..3419dbbabe6 100644 --- a/docs-site/src/content/docs/fr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md @@ -37,6 +37,8 @@ remplissage automatique. Le tableau de bord lui-même ne conserve le jeton qu'en dans `localStorage` ni dans `sessionStorage` ; son enregistrement dépend entièrement du navigateur ou du gestionnaire de mots de passe. +**Mémoriser cet appareil** est désactivé par défaut. Sur un tableau de bord HTTPS direct (ou en boucle locale), le serveur échange le jeton administrateur contre un cookie HttpOnly, SameSite=Strict, limité à cet hôte et Secure en HTTPS. Aucun jeton administrateur n’est stocké dans localStorage ou sessionStorage. Le fichier privé dashboard-sessions.json conserve uniquement les empreintes du cookie, du jeton et du navigateur ainsi qu’une expiration fixe de 30 jours. La relance de l’application Safari reprend une session courte tant que cet appareil reste autorisé. **Oublier cet appareil** dans les actions mobiles ou la barre latérale, ainsi que la déconnexion, révoque cet appareil et toutes ses sessions. L’expiration, un changement d’identité du navigateur ou la rotation du jeton suivie d’un redémarrage impose une nouvelle connexion. Les tableaux de bord relayés, inter-origines ou HTTP distant ne prennent pas en charge cette option. Les anciennes clés de jeton mémorisé sont supprimées sans lecture ni migration. Réservez-la aux appareils de confiance ; elle ne rétablit pas l’AutoFill de Safari. + ## Barre de résumé des quotas Une ligne de résumé en haut de chaque page, sauf la page Sécurité au démarrage, indique diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index d808779cd34..53b00f72892 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -35,6 +35,8 @@ password manager can offer to save and autofill it. The dashboard itself still k in memory and does not write it to `localStorage` or `sessionStorage`; whether it is saved is entirely the browser or password manager's decision. +**Remember this device** is off by default. On a direct HTTPS dashboard (or local loopback), it exchanges the admin token for a server-issued HttpOnly, SameSite=Strict device cookie. HTTPS cookies are Secure and host-only. The browser never stores the admin token in localStorage or sessionStorage. The server stores only a cookie hash, an admin-credential fingerprint, browser identity hash and a fixed 30-day expiry in its private dashboard-sessions.json file. Relaunching the Safari home-screen app resumes a short-lived GUI session while that device record is valid. **Forget this device** in the sidebar or mobile action bar (and session logout) revokes the record and all sessions resumed from it. Expiry, a browser identity change, or rotating the admin token and restarting the server requires sign-in again. Cross-origin/relayed dashboards and remote plaintext HTTP do not support this option. Older remembered bearer-token keys are removed without being read or migrated. Use only on a device you trust. This does not make Safari offer password AutoFill. + ### Finding the admin token You only need this on a non-loopback bind. A local dashboard never asks, and if a local one diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index bc1e933f309..3b59f7d7a4c 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -27,6 +27,8 @@ bun run dev:gui リモートダッシュボードでは標準のパスワードフォームが表示され、ブラウザのパスワードマネージャーで保存・自動入力できます。ダッシュボード自体はトークンをメモリ内だけに保持し、`localStorage` や `sessionStorage` には書き込みません。保存するかどうかはブラウザまたはパスワードマネージャーだけが決定します。 +**このデバイスを記憶する** は既定でオフです。直接アクセスする HTTPS ダッシュボード(またはローカルループバック)で、管理者トークンをサーバー発行の HttpOnly・SameSite=Strict Cookie に交換します。HTTPS では Secure とホスト限定が適用されます。管理者トークンを localStorage や sessionStorage に保存しません。非公開の dashboard-sessions.json には Cookie・認証情報・ブラウザーのハッシュと固定30日間の有効期限だけを保存します。Safari ホーム画面アプリを再起動すると、有効なデバイス記録から短期セッションを再開します。サイドバーやモバイル操作の **このデバイスを解除**、またはログアウトで記録と派生セッションを失効させます。期限切れ、ブラウザー識別の変更、管理者トークン変更後のサーバー再起動では再ログインが必要です。中継・別オリジン・リモート HTTP では使えません。旧トークン保存キーは読み取りや移行をせず削除します。信頼できるデバイスのみで使用してください。Safari の自動入力を有効にするものではありません。 + ## クォータ概要バー 起動安全性ページを除くすべてのページ上部の 1 行の概要に、各プロバイダーの現在のクォータ使用率が表示されます。例: `OpenAI 31% | Claude 54% | xAI 12% | Google 8%`。プロバイダー画面と同じクォータレポート(`GET /api/provider-quotas`、タブが表示されている間は 60 秒ごと)を読み取り、上流への強制更新は行いません。 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 d014bf6f192..585aa97ed38 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -27,6 +27,8 @@ bun run dev:gui 원격 대시보드는 표준 비밀번호 폼을 표시하므로 브라우저 비밀번호 관리자가 토큰 저장과 자동 완성을 제안할 수 있습니다. 대시보드 자체는 토큰을 메모리에만 보관하며 `localStorage`나 `sessionStorage`에 쓰지 않습니다. 저장 여부는 전적으로 브라우저 또는 비밀번호 관리자가 결정합니다. +**이 기기 기억하기**는 기본적으로 꺼져 있습니다. 직접 HTTPS 대시보드(또는 로컬 루프백)에서 관리자 토큰을 서버 발급 HttpOnly, SameSite=Strict 쿠키로 교환합니다. HTTPS 쿠키는 Secure이며 해당 호스트에만 적용됩니다. 관리자 토큰은 localStorage나 sessionStorage에 저장하지 않습니다. 비공개 dashboard-sessions.json에는 쿠키·인증 정보·브라우저 해시와 고정 30일 만료만 저장합니다. Safari 홈 화면 앱을 다시 열면 유효한 기기 기록으로 단기 세션을 재개합니다. 사이드바나 모바일 작업의 **이 기기 잊기** 및 로그아웃은 기기 기록과 모든 파생 세션을 취소합니다. 만료, 브라우저 식별 변경, 토큰 교체 후 서버 재시작 시 다시 로그인해야 합니다. 릴레이·다른 출처·원격 HTTP에서는 지원하지 않습니다. 이전 토큰 저장 키는 읽거나 마이그레이션하지 않고 삭제합니다. 신뢰하는 기기에서만 사용하세요. Safari 자동 완성을 복원하는 기능은 아닙니다. + ## 사용량 요약 바 시작 안전성 페이지를 제외한 모든 페이지 상단의 한 줄 요약에 프로바이더별 현재 할당량 사용률이 표시됩니다. 예: `OpenAI 31% | Claude 54% | xAI 12% | Google 8%`. 프로바이더 작업 화면과 같은 할당량 보고(`GET /api/provider-quotas`)를 탭이 보이는 동안 60초마다 읽으며, 업스트림 강제 새로고침은 하지 않습니다. diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index bb44e1ee576..db5fb56f899 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -27,6 +27,8 @@ bun run dev:gui Удалённый дашборд показывает стандартную форму пароля, поэтому менеджер паролей браузера может предложить сохранить и автозаполнять токен. Сам дашборд хранит токен только в памяти и не записывает его в `localStorage` или `sessionStorage`; решение о сохранении полностью остаётся за браузером или менеджером паролей. +**Запомнить это устройство** по умолчанию выключено. При прямом HTTPS-доступе (или локальном loopback) сервер заменяет токен администратора на HttpOnly, SameSite=Strict cookie устройства; в HTTPS cookie имеет Secure и привязку к хосту. Токен не записывается в localStorage или sessionStorage. Приватный dashboard-sessions.json хранит только хеши cookie, учётных данных и браузера и фиксированный срок 30 дней. Повторный запуск приложения Safari возобновляет короткую сессию, пока запись действительна. **Забыть это устройство** в боковой панели или мобильных действиях, как и выход, отзывает запись и все связанные сессии. Истечение срока, смена идентичности браузера или смена токена с перезапуском сервера требуют нового входа. Релейный, междоменный и удалённый HTTP-доступ не поддерживаются. Старые ключи сохранённых токенов удаляются без чтения или миграции. Используйте только доверенное устройство; AutoFill Safari это не восстанавливает. + ## Полоса сводки квот Одна строка в верхней части каждой страницы, кроме страницы «Безопасность запуска», показывает текущее использование квот каждым провайдером, например `OpenAI 31% | Claude 54% | xAI 12% | Google 8%`. Она читает те же отчёты о квотах провайдеров, что и рабочая область провайдера (`GET /api/provider-quotas`, каждые 60 секунд, пока вкладка видима), и никогда не форсирует обновление на стороне провайдера. diff --git a/docs-site/src/content/docs/tr/guides/web-dashboard.md b/docs-site/src/content/docs/tr/guides/web-dashboard.md index 3fc797454c9..619890f7569 100644 --- a/docs-site/src/content/docs/tr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md @@ -39,6 +39,8 @@ yalnızca bellekte tutar ve `localStorage` veya `sessionStorage`'a yazmaz; kaydedilip kaydedilmeyeceği tamamen tarayıcının veya şifre yöneticisinin kararıdır. +**Bu cihazı hatırla** varsayılan olarak kapalıdır. Doğrudan HTTPS panelinde (veya yerel loopback üzerinden) yönetici belirteci, sunucunun verdiği HttpOnly, SameSite=Strict cihaz çereziyle değiştirilir; HTTPS çerezi Secure ve yalnızca bu sunucuya özeldir. Yönetici belirteci localStorage veya sessionStorage içine yazılmaz. Özel dashboard-sessions.json yalnızca çerez, kimlik bilgisi ve tarayıcı özetleriyle sabit 30 günlük süreyi saklar. Safari ana ekran uygulaması yeniden açılınca geçerli cihaz kaydı kısa bir oturumu yeniler. Kenar çubuğu veya mobil işlemlerde **Bu cihazı unut** ve oturumu kapatma, kaydı ve türetilen tüm oturumları iptal eder. Süre dolması, tarayıcı kimliği değişmesi veya belirtecin değiştirilip sunucunun yeniden başlatılması yeniden giriş gerektirir. Relay, başka origin ve uzak düz HTTP desteklenmez. Eski belirteç anahtarları okunmadan veya taşınmadan silinir. Yalnızca güvenilir cihazda kullanın; Safari AutoFill özelliğini geri getirmez. + ## Kota özeti çubuğu Başlangıç güvenliği sayfası dışındaki her sayfanın üst kısmındaki tek satırlık özet, her diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 044fd71f388..c4a511be3b7 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -26,6 +26,8 @@ bun run dev:gui 远程仪表盘会显示标准密码表单,浏览器密码管理器可以提示保存并自动填充 token。仪表盘本身只在内存中保存 token,不会写入 `localStorage` 或 `sessionStorage`;是否持久保存完全由浏览器或密码管理器决定。 +**记住此设备** 默认关闭。直接 HTTPS 仪表板(或本地回环)会将管理员令牌交换为服务器签发的 HttpOnly、SameSite=Strict 设备 Cookie;HTTPS 下启用 Secure,且仅限本主机。管理员令牌不存入 localStorage 或 sessionStorage。私有 dashboard-sessions.json 仅保存 Cookie、凭据和浏览器哈希及固定30天有效期。重新打开 Safari 主屏幕应用时,有效设备记录可恢复短期会话。侧栏或移动操作中的 **忘记此设备** 以及退出登录,会撤销该记录及其所有会话。过期、浏览器标识改变,或更换管理员令牌并重启服务器后需重新登录。中继、跨来源和远程明文 HTTP 不支持此选项。旧令牌存储键会直接删除,不读取或迁移。仅在可信设备启用;不会恢复 Safari 密码自动填充。 + ## 配额摘要栏 除启动安全页面外,每个页面顶部的一行摘要会显示各 provider 当前的配额用量,例如 `OpenAI 31% | Claude 54% | xAI 12% | Google 8%`。它读取与提供方工作区相同的配额报告(`GET /api/provider-quotas`,标签页可见时每 60 秒一次),并且绝不会强制刷新上游。 diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md index 9128bf87e47..5f2230ebe62 100644 --- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md @@ -31,6 +31,8 @@ GUI session 簽發到服務的頁面中,並在到期或代理重啟時靜默 儀表板本身仍然只在記憶體中保留 token,不會寫入 `localStorage` 或 `sessionStorage`;是否儲存完全 由瀏覽器或密碼管理員決定。 +**記住此裝置** 預設關閉。直接 HTTPS 儀表板(或本機回環)會將管理員權杖交換為伺服器簽發的 HttpOnly、SameSite=Strict 裝置 Cookie;HTTPS 下使用 Secure,且僅限本主機。管理員權杖不存入 localStorage 或 sessionStorage。私有 dashboard-sessions.json 僅保存 Cookie、憑證和瀏覽器雜湊及固定30天有效期。重新開啟 Safari 主畫面應用程式時,有效裝置記錄可恢復短期工作階段。側欄或行動操作中的 **忘記此裝置** 以及登出,會撤銷記錄及其所有工作階段。過期、瀏覽器識別變更,或更換管理員權杖並重新啟動伺服器後需重新登入。中繼、跨來源和遠端明文 HTTP 不支援此選項。舊權杖儲存鍵會直接刪除,不讀取或遷移。僅在可信裝置啟用;不會恢復 Safari 密碼自動填入。 + ## 配額摘要列 除啟動安全頁面外,每個頁面頂端的一行摘要會顯示各供應商目前的配額用量,例如 diff --git a/gui/tests/persistent-dashboard.test.ts b/gui/tests/persistent-dashboard.test.ts index 974c52ccc69..e7a226be358 100644 --- a/gui/tests/persistent-dashboard.test.ts +++ b/gui/tests/persistent-dashboard.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { promptForAdminToken } from "../src/admin-token-dialog"; -import { hasApiSession, installApiAuthFetch, resetApiAuthFetchForTests } from "../src/api"; +import { hasApiSession, installApiAuthFetch, resetApiAuthFetchForTests, setRebootstrapTimeoutForTests } from "../src/api"; const names = ["window", "document", "navigator", "localStorage", "sessionStorage", "HTMLElement"] as const; let previous: unknown[]; @@ -55,3 +55,19 @@ test("obsolete remembered bearer tokens are removed without reading or migrating expect(getter("opencodex.remembered-admin-token:https://server|same-origin")).toBeNull(); expect(getter("opencodex.remembered-admin-token-decoy")).toBe("unrelated"); }); + +test("a hung Remember response body is aborted and cannot install a session", async () => { + let verdict: unknown; let attempts = 0; let signal: AbortSignal | undefined; + resetApiAuthFetchForTests(async verify => { verdict = await verify("ocx_admin_fixture", true); return null; }); + setRebootstrapTimeoutForTests(20); + window.fetch = (async (input, init) => { + if (!String(input).endsWith("/api/session/remember")) return new Response(null, { status: 401 }); + attempts++; signal = init?.signal as AbortSignal; + const response = new Response(null); + response.json = () => new Promise(() => {}); + return response; + }) as typeof fetch; + installApiAuthFetch(); + expect((await window.fetch("/api/config")).status).toBe(401); + expect(attempts).toBe(1); expect(verdict).toBe("unavailable"); expect(signal?.aborted).toBe(true); expect(hasApiSession("shared")).toBe(false); +}); diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 3cbebeb3a11..15963c6d0f0 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -7,7 +7,7 @@ in [GUI and management API](gui-and-management-api.md). ## UX boundary The dashboard is a local control surface, not a separate service. It should reflect the same config -and catalog invariants documented in this folder rather than inventing parallel state. +and catalog invariants documented in this folder rather than inventing parallel state. The sign-in form offers opt-in **Remember this device** for direct HTTPS dashboards or loopback, requesting a server-issued HttpOnly session rather than storing an admin token. Both mobile and sidebar actions offer **Forget this device**, using logout to revoke durable and transient device authority; session-change events update both surfaces. Expiry and cookie authentication belong to [GUI and management API](gui-and-management-api.md). Codex quota cards consume the display cache from `src/codex/quota.ts`. Partial refreshes remove omitted short tuples after their reset deadline; validated main-window absence also removes diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 961a55f07ed..1c18024ca79 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -119,7 +119,7 @@ OpenCodex uses three mutually exclusive reusable admission credential classes: The service token file remains a delivery mechanism for the data-plane environment token; it is not a fourth credential class. A management credential that equals any configured data-plane credential -does not enable management access. The data plane may continue to start, but `/api/*` remains closed. +does not enable management access. The data plane may continue to start, but `/api/*` remains closed. The opt-in bodyless `POST /api/session/remember` exchanges admin-token authority for a host-only HttpOnly, SameSite=Strict device cookie (Secure on HTTPS), never browser-stored admin credentials. `src/server/persistent-gui-session.ts` owns the private hash-only `dashboard-sessions.json` store (128 devices/128 KiB) and fixed 30-day expiry. Enrollment requires direct same-origin HTTPS or loopback; relay, cross-origin and remote HTTP are refused. Cookie hashes bind server origin (including port), browser identity and admin-credential fingerprint. Bootstrap issues short-lived origin/CSRF-bound `remembered-admin` sessions, which never count as pairing or data-key-reveal consent. Every derived session still requires the cookie and durable record; logout durably revokes both before reporting success. Legacy bearer-token storage is purged without reading or migrating it. Tests: `tests/server/persistent-dashboard-session.test.ts`. CLI health collection follows the same boundary without transporting the reusable management credential. Its local-read HMAC capability is an additional single-use, route-scoped admission mechanism, not a reusable credential class. `ocx doctor` and OAuth health derive these capabilities diff --git a/tests/server/persistent-dashboard-session.test.ts b/tests/server/persistent-dashboard-session.test.ts index 3925136b429..3ffa44b5f64 100644 --- a/tests/server/persistent-dashboard-session.test.ts +++ b/tests/server/persistent-dashboard-session.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { initializeManagementAuthState, createManagementSessionControl, issueGuiSession, requireManagementAuth } from "../../src/server/management-auth"; @@ -80,3 +80,24 @@ test("remember rejects cross-origin, plaintext remote and unauthenticated issuan } const req = request(); expect(handleSessionRoutes({ req, url: new URL(req.url), config, principal: "gui-session", sessionControl: control } as ManagementContext)?.status).toBe(403); }); + +test("the persistent cookie alone grants no management access, and a derived token needs its cookie", async () => { + const { state, response } = login(); expect(response?.status).toBe(200); + const cookie = response!.headers.get("set-cookie")!.split(";")[0]!; + const session = (await response!.json()).session; + const cookieOnly = request("/api/config", cookie, {}, "GET"); cookieOnly.headers.delete("X-OpenCodex-API-Key"); + expect(requireManagementAuth(cookieOnly, state, config)?.status).toBe(401); + const tokenOnly = request("/api/config", undefined, { "X-OpenCodex-API-Key": session.token, "X-OpenCodex-GUI-Origin": origin }, "GET"); + expect(requireManagementAuth(tokenOnly, state, config)?.status).toBe(401); + const both = request("/api/config", cookie, { "X-OpenCodex-API-Key": session.token, "X-OpenCodex-GUI-Origin": origin }, "GET"); + const control = createManagementSessionControl(state); + expect(requireManagementAuth(both, state, config)).toBeNull(); + expect(control.isPaired(both, config)).toBe(false); expect(control.canRevealDataKeys?.(both, config)).toBe(false); +}); + +test("an unreadable or malformed device store fails closed without issuing a cookie or overwriting it", () => { + writeFileSync(join(home, "dashboard-sessions.json"), "not-json", { mode: 0o600 }); + const { response } = login(); expect(response?.status).toBe(503); + expect(response!.headers.get("set-cookie")).toBeNull(); + expect(readFileSync(join(home, "dashboard-sessions.json"), "utf8")).toBe("not-json"); +}); From f55f9f912df178aadebb5fdceb4c0c4939bd4de5 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 18 Oct 2026 04:44:25 +0900 Subject: [PATCH 06/10] fix(auth): bind remembered dashboard devices to an origin-scoped proof Cookies are shared across ports of one hostname, so the HttpOnly device cookie alone no longer resumes a remembered session: Remember also returns a separate device proof that the dashboard keeps in origin-scoped storage and sends only on its session re-bootstrap. The proof is checked inside resumeGuiDevice before any session is minted, covering every bootstrap entry point. Remembered sessions are derived from the admin token, so casual-path consent guards (GitHub star, reset-grant consume, prompt-layer writes, Meta Muse sign-in, desktop compatibility changes, account-refresh validation) treat them like the admin token. Remember/Forget publish the store through a no-follow writer that fsyncs content before rename; directory sync stays best effort. Adds admission-level regression coverage through real management dispatch. --- scripts/test-layout/layout.json | 2 + src/config/atomic-write.ts | 9 + src/server/management-api.ts | 3 +- .../anthropic-reset-grant-routes.ts | 3 +- src/server/management/codex-prompt-routes.ts | 3 +- src/server/management/consent-session.ts | 6 + .../desktop-compatibility-routes.ts | 3 +- .../desktop-compatibility-runtime-routes.ts | 3 +- .../desktop-compatibility-settings-routes.ts | 3 +- src/server/management/oauth-account-routes.ts | 15 +- src/server/management/session-routes.ts | 2 +- src/server/management/sidebar-routes.ts | 3 +- src/server/persistent-gui-session.ts | 25 +- .../atomic-write-nofollow-streamed.test.ts | 71 ++++ tests/fixtures/test-layout-expected.json | 2 + .../persistent-dashboard-admission.test.ts | 348 ++++++++++++++++++ .../persistent-dashboard-session.test.ts | 20 +- 17 files changed, 490 insertions(+), 31 deletions(-) create mode 100644 src/server/management/consent-session.ts create mode 100644 tests/config/atomic-write-nofollow-streamed.test.ts create mode 100644 tests/server/persistent-dashboard-admission.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0fb8750a622..e06d393d926 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1661,6 +1661,8 @@ "server-loopback-host-gate.test.ts": "server", "server-management-auth.test.ts": "server", "server-management-log-admission.test.ts": "server", "persistent-dashboard-session.test.ts": "server", + "persistent-dashboard-admission.test.ts": "server", + "atomic-write-nofollow-streamed.test.ts": "config", "server-opencode-go-goal-streaming.test.ts": "server", "server-rate-limit-retry-e2e.test.ts": "server", "server-request-body-size.test.ts": "server", diff --git a/src/config/atomic-write.ts b/src/config/atomic-write.ts index 9e0452741bb..e014da244cc 100644 --- a/src/config/atomic-write.ts +++ b/src/config/atomic-write.ts @@ -408,6 +408,15 @@ export function atomicWriteFileNoFollow( atomicWriteFileToTarget(path, content, join(resolveWriteTarget(dirname(path)), basename(path)), io, hooks); } +/** Content fsync before rename is mandatory; parent-directory sync is best effort. */ +export function atomicWriteFileNoFollowStreamed( + path: string, + write: (descriptor: number) => void, + hooks: AtomicWriteHooks = {}, +): void { + atomicWriteFileToTarget(path, write, join(resolveWriteTarget(dirname(path)), basename(path)), undefined, hooks); +} + /** * The no-follow replacement above, for rewriting a file whose uninstall ownership must stay as * it was: it does not record the path in the owner manifest. Used to rewrite the OAuth downgrade diff --git a/src/server/management-api.ts b/src/server/management-api.ts index f6076920a48..aa4a06c72de 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -525,7 +525,8 @@ export async function handleManagementAPI( const { ConfigMutationLockError } = await import("../config"); const { CodexCredentialRefreshLockTimeoutError } = await import("../codex/account-store"); try { - return await handleCodexAuthAPI(req, url, config, convergeCodexCatalog, principal); + // Remembered sessions retain admin-token consent authority for account refresh. + return await handleCodexAuthAPI(req, url, config, convergeCodexCatalog, ctx.guiSessionIssuance === "remembered-admin" ? "admin-token" : principal); } catch (error) { // Credential writers remap ConfigMutationLockError to CodexCredentialRefreshLockTimeoutError; // treat both as the same retryable busy response. diff --git a/src/server/management/anthropic-reset-grant-routes.ts b/src/server/management/anthropic-reset-grant-routes.ts index 0c11f2ecac3..b1c38752db7 100644 --- a/src/server/management/anthropic-reset-grant-routes.ts +++ b/src/server/management/anthropic-reset-grant-routes.ts @@ -1,3 +1,4 @@ +import { hasHumanDashboardSession } from "./consent-session"; /** * Management API for Anthropic OAuth usage-limit reset grants. * @@ -182,7 +183,7 @@ async function handleRead(ctx: ManagementContext, deps: AnthropicResetGrantRoute } async function handleConsume(ctx: ManagementContext, injected?: AnthropicResetGrantRouteDeps): Promise { - if (ctx.principal !== "gui-session") return fail(ctx, 403, "session_required"); + if (!hasHumanDashboardSession(ctx)) return fail(ctx, 403, "session_required"); let body: Record; try { const parsed = await ctx.req.json() as unknown; diff --git a/src/server/management/codex-prompt-routes.ts b/src/server/management/codex-prompt-routes.ts index 06a927f934b..cde65daf9b2 100644 --- a/src/server/management/codex-prompt-routes.ts +++ b/src/server/management/codex-prompt-routes.ts @@ -1,3 +1,4 @@ +import { hasHumanDashboardSession } from "./consent-session"; /** * /api/codex-prompt — the Codex prompt-layer surface. * @@ -303,7 +304,7 @@ export async function handleCodexPromptRoutes(ctx: ManagementContext): Promise): boolean { + return ctx.principal === "gui-session" && ctx.guiSessionIssuance !== "remembered-admin"; +} diff --git a/src/server/management/desktop-compatibility-routes.ts b/src/server/management/desktop-compatibility-routes.ts index af67e429a51..4056dcf22fd 100644 --- a/src/server/management/desktop-compatibility-routes.ts +++ b/src/server/management/desktop-compatibility-routes.ts @@ -1,3 +1,4 @@ +import { hasHumanDashboardSession } from "./consent-session"; import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; @@ -11,7 +12,7 @@ export async function handleDesktopCompatibilityRoutes(ctx: ManagementContext): if (ctx.req.method !== "GET" && ctx.req.method !== "POST") return jsonResponse({ error: "method_not_allowed" }, 405); // A local dashboard confirmation precedes any persisted key or CurrentUser Root mutation. // This is caller provenance, not protection against an arbitrary same-user process. - if (ctx.req.method === "POST" && (ctx.principal !== "gui-session" || !ctx.trustedLoopbackIngress)) { + if (ctx.req.method === "POST" && (!hasHumanDashboardSession(ctx) || !ctx.trustedLoopbackIngress)) { return jsonResponse({ error: "local_dashboard_confirmation_required" }, 403); } let action: "prepare" | "trust" | "remove-trust" | "renew" | undefined, fingerprint: string | undefined; diff --git a/src/server/management/desktop-compatibility-runtime-routes.ts b/src/server/management/desktop-compatibility-runtime-routes.ts index eeb4bbe2b47..a1e94e92e09 100644 --- a/src/server/management/desktop-compatibility-runtime-routes.ts +++ b/src/server/management/desktop-compatibility-runtime-routes.ts @@ -1,3 +1,4 @@ +import { hasHumanDashboardSession } from "./consent-session"; import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; @@ -11,7 +12,7 @@ const ERROR_CODES = new Set(["busy", "unsupported", "test_environment", "stoppin export async function handleDesktopCompatibilityRuntimeRoutes(ctx: ManagementContext): Promise { if (ctx.url.pathname !== PATH) return null; if (ctx.req.method !== "GET" && ctx.req.method !== "POST") return jsonResponse({ error: "method_not_allowed" }, 405); - if (ctx.req.method === "POST" && (ctx.principal !== "gui-session" || !ctx.trustedLoopbackIngress)) return jsonResponse({ error: "local_dashboard_confirmation_required" }, 403); + if (ctx.req.method === "POST" && (!hasHumanDashboardSession(ctx) || !ctx.trustedLoopbackIngress)) return jsonResponse({ error: "local_dashboard_confirmation_required" }, 403); let action: "start" | "stop" | "observe" | "apply" | "launch" | undefined; if (ctx.req.method === "POST") { let body: unknown; diff --git a/src/server/management/desktop-compatibility-settings-routes.ts b/src/server/management/desktop-compatibility-settings-routes.ts index b051292673f..b27cc99277c 100644 --- a/src/server/management/desktop-compatibility-settings-routes.ts +++ b/src/server/management/desktop-compatibility-settings-routes.ts @@ -1,3 +1,4 @@ +import { hasHumanDashboardSession } from "./consent-session"; import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; @@ -6,7 +7,7 @@ const PATH = "/api/codex/desktop-compatibility/settings"; export async function handleDesktopCompatibilitySettingsRoutes(ctx: ManagementContext): Promise { if (ctx.url.pathname !== PATH) return null; if (ctx.req.method !== "GET" && ctx.req.method !== "POST") return jsonResponse({ error: "method_not_allowed" }, 405); - if (ctx.req.method === "POST" && (ctx.principal !== "gui-session" || !ctx.trustedLoopbackIngress)) return jsonResponse({ error: "local_dashboard_confirmation_required" }, 403); + if (ctx.req.method === "POST" && (!hasHumanDashboardSession(ctx) || !ctx.trustedLoopbackIngress)) return jsonResponse({ error: "local_dashboard_confirmation_required" }, 403); let update: { startOnProxyStart: boolean; revision: string } | undefined; if (ctx.req.method === "POST") { let body: unknown; diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 3f48867ed6f..b30919819d9 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -1,3 +1,4 @@ +import { hasHumanDashboardSession } from "./consent-session"; import { isAnthropicOAuthInstance, configuredAnthropicInstance, type AnthropicInstanceId } from "../../providers/anthropic-instance"; import { resolveAnthropicAccountPoolConfig } from "../../oauth/anthropic-pool-config"; import { persistAnthropicPoolPatch, writeAnthropicPoolSettings } from "./anthropic-pool-settings"; @@ -214,12 +215,12 @@ export function revokeApiKeyInProcess(config: OcxConfig, id: string): boolean { return true; } -function canStartManagementOAuth(provider: string, principal: ManagementContext["principal"]): boolean { - return provider !== "meta-muse" || principal === "gui-session"; +function canStartManagementOAuth(provider: string, ctx: ManagementContext): boolean { + return provider !== "meta-muse" || hasHumanDashboardSession(ctx); } -function metaMuseConsentRequired(provider: string, principal: ManagementContext["principal"]): Response | null { - if (canStartManagementOAuth(provider, principal)) return null; +function metaMuseConsentRequired(provider: string, ctx: ManagementContext): Response | null { + if (canStartManagementOAuth(provider, ctx)) return null; return jsonResponse({ error: "Meta Muse login requires acknowledgement in the OpenCodex dashboard.", code: "oauth_consent_required", @@ -246,7 +247,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< if (url.pathname === "/api/oauth/providers" && req.method === "GET") { // Discovery reflects this principal's admission; hiding a button is not the // consent boundary, which remains independently enforced on both POST routes. - return jsonResponse({ providers: listOAuthProviders().filter(provider => canStartManagementOAuth(provider, principal)) }); + return jsonResponse({ providers: listOAuthProviders().filter(provider => canStartManagementOAuth(provider, ctx)) }); } // API-key "login" providers (open dashboard → paste key). Drives the GUI's key-provider picker. @@ -273,7 +274,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< // and reauth skip the import. All management login paths require the dashboard // principal before credential acquisition. A raw token proves administration, // not acknowledgement; caller-supplied headers are not consent evidence. - const consentRequired = metaMuseConsentRequired(provider, principal); + const consentRequired = metaMuseConsentRequired(provider, ctx); if (consentRequired) return consentRequired; const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider); if (namespaceCollision) return jsonResponse({ error: namespaceCollision }, 409); @@ -389,7 +390,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; input?: string; code?: string; flowId?: unknown }; const provider = (body.provider ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); - const consentRequired = metaMuseConsentRequired(provider, principal); + const consentRequired = metaMuseConsentRequired(provider, ctx); if (consentRequired) return consentRequired; const flowId = typeof body.flowId === "string" ? body.flowId : undefined; const maskEmails = emailMaskingEnabled(config); diff --git a/src/server/management/session-routes.ts b/src/server/management/session-routes.ts index 22e493fe016..107a345818d 100644 --- a/src/server/management/session-routes.ts +++ b/src/server/management/session-routes.ts @@ -7,7 +7,7 @@ export function handleSessionRoutes(ctx: ManagementContext): Response | null { try { const remembered = ctx.sessionControl?.rememberDevice?.(ctx.req, ctx.config); if (!remembered) return jsonResponse({ error: "Remember requires a direct HTTPS dashboard (or loopback) and matching Origin" }, 403, ctx.req, ctx.config); - const response = jsonResponse({ session: remembered.session }, 200, ctx.req, ctx.config); + const response = jsonResponse({ session: remembered.session, device: remembered.device }, 200, ctx.req, ctx.config); response.headers.set("Set-Cookie", remembered.cookie); response.headers.set("Cache-Control", "no-store"); return response; diff --git a/src/server/management/sidebar-routes.ts b/src/server/management/sidebar-routes.ts index a2eaffed014..4d5a24f5834 100644 --- a/src/server/management/sidebar-routes.ts +++ b/src/server/management/sidebar-routes.ts @@ -1,3 +1,4 @@ +import { hasHumanDashboardSession } from "./consent-session"; /** * /api/github/star and /api/update/badge — the two cheap polls behind the * sidebar's GitHub star and update controls. @@ -40,7 +41,7 @@ import type { ManagementContext } from "./context"; * request an agent cannot fabricate, so that is what this now reads. */ function hasBrowserSessionEvidence(ctx: ManagementContext): boolean { - return ctx.principal === "gui-session"; + return hasHumanDashboardSession(ctx); } // Known edge, deliberately fail-closed: a non-loopback operator dashboard that signs diff --git a/src/server/persistent-gui-session.ts b/src/server/persistent-gui-session.ts index 4c5c42215a5..da0e73e8879 100644 --- a/src/server/persistent-gui-session.ts +++ b/src/server/persistent-gui-session.ts @@ -1,9 +1,9 @@ import { createHash, randomBytes } from "node:crypto"; -import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs"; +import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { getConfigDir } from "../config/paths"; import { withConfigMutationLockSync } from "../config/mutation-lock"; -import { atomicWriteFileNoFollow } from "../config/atomic-write"; +import { atomicWriteFileNoFollowStreamed } from "../config/atomic-write"; import type { OcxConfig } from "../types"; import { isLoopbackHostname, isTrustedHubManagementRequest, managementRequestOrigin } from "./auth-cors"; import { mintSession, type GuiSessionRecord, type GuiSessionState } from "./gui-session"; @@ -15,6 +15,7 @@ type State = GuiSessionState & { token: string }; interface DeviceRecord { digest: string; credential: string; + proof: string; serverOrigin: string; userAgent: string; expiresAt: number; @@ -26,6 +27,8 @@ function directOrigin(req: Request, config: OcxConfig): string | null { const origin = managementRequestOrigin(req, config); if (!origin) return null; const url = new URL(origin); + // The process-marked hub management listener sits behind a local TLS terminator, so its + // request URL is http: while the canonical public origin is https:. Nothing forwarded is trusted. if (new URL(req.url).origin !== origin && !isTrustedHubManagementRequest(req)) return null; if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) return null; const browser = req.headers.get("Origin"); @@ -68,8 +71,8 @@ function readRecords(): DeviceRecord[] { const value: unknown = JSON.parse(bytes.subarray(0, length).toString("utf8")); if (!Array.isArray(value) || value.length > RECORD_LIMIT) throw new Error("invalid dashboard session store"); for (const row of value) { - if (!row || typeof row !== "object" || Object.keys(row).length !== 5 - || ![row.digest, row.credential, row.userAgent].every(v => typeof v === "string" && /^[a-f0-9]{64}$/.test(v)) + if (!row || typeof row !== "object" || Object.keys(row).length !== 6 + || ![row.digest, row.credential, row.proof, row.userAgent].every(v => typeof v === "string" && /^[a-f0-9]{64}$/.test(v)) || typeof row.serverOrigin !== "string" || row.serverOrigin.length > 2048 || !Number.isSafeInteger(row.expiresAt) || row.expiresAt <= 0) throw new Error("invalid dashboard session record"); const url = new URL(row.serverOrigin); @@ -103,10 +106,11 @@ export function rememberGuiDevice(req: Request, config: OcxConfig, state: State, const retained = rows.filter(row => !(row.digest === previous && matches(row, req, origin, state, now))); if (retained.length >= RECORD_LIMIT) throw new Error("dashboard device limit reached"); const token = "ocx_device_" + randomBytes(32).toString("base64url"); - const row: DeviceRecord = { digest: hash(token), credential: hash(state.token), serverOrigin: origin, + const proof = "ocx_proof_" + randomBytes(32).toString("base64url"); + const row: DeviceRecord = { digest: hash(token), credential: hash(state.token), proof: hash(proof), serverOrigin: origin, userAgent: hash(req.headers.get("User-Agent") ?? ""), expiresAt: now + REMEMBERED_GUI_TTL_MS }; - atomicWriteFileNoFollow(storePath(), JSON.stringify([...retained, row])); - return { session: mintDeviceSession(row, state, now), cookie: cookie(origin, token, REMEMBERED_GUI_TTL_MS / 1000) }; + atomicWriteFileNoFollowStreamed(storePath(), fd => writeFileSync(fd, JSON.stringify([...retained, row]))); + return { session: mintDeviceSession(row, state, now), device: { proof }, cookie: cookie(origin, token, REMEMBERED_GUI_TTL_MS / 1000) }; }); } @@ -114,9 +118,10 @@ export function resumeGuiDevice(req: Request, config: OcxConfig, state: State, n const origin = directOrigin(req, config); if (req.method !== "GET" || !origin) return null; const digest = deviceDigest(req, origin); - if (!digest) return null; + const proof = req.headers.get("X-OpenCodex-Device-Proof"); + if (!digest || !proof || !/^ocx_proof_[A-Za-z0-9_-]{43}$/.test(proof)) return null; try { - const row = readRecords().find(row => row.digest === digest && matches(row, req, origin, state, now)); + const row = readRecords().find(row => row.digest === digest && row.proof === hash(proof) && matches(row, req, origin, state, now)); return row ? mintDeviceSession(row, state, now) : null; } catch { return null; } } @@ -136,7 +141,7 @@ export function forgetGuiDevice(req: Request, config: OcxConfig, state: State): if (digest) withConfigMutationLockSync(() => { const rows = readRecords(); const found = rows.find(row => row.digest === digest && matches(row, req, origin, state, Date.now())); - if (found) atomicWriteFileNoFollow(storePath(), JSON.stringify(rows.filter(row => row !== found))); + if (found) atomicWriteFileNoFollowStreamed(storePath(), fd => writeFileSync(fd, JSON.stringify(rows.filter(row => row !== found)))); for (const [token, session] of state.sessions) if (session.persistentDigest === digest) state.sessions.delete(token); }); return cookie(origin, "", 0); diff --git a/tests/config/atomic-write-nofollow-streamed.test.ts b/tests/config/atomic-write-nofollow-streamed.test.ts new file mode 100644 index 00000000000..7aa3713b3cc --- /dev/null +++ b/tests/config/atomic-write-nofollow-streamed.test.ts @@ -0,0 +1,71 @@ +import { afterAll, afterEach, beforeEach, expect, mock, test } from "bun:test"; +import * as fs from "node:fs"; +import { dirname } from "node:path"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +// Install the fs wrapper before importing either publisher or rename helper. +const native = { ...fs }; +const descriptors = new Map(); +let target = ""; +let failure: "temp" | "directory" | null = null; +let events: string[] = []; +mock.module("node:fs", () => ({ + ...native, + openSync: (...args: Parameters) => { + const descriptor = native.openSync(...args); + descriptors.set(descriptor, String(args[0])); return descriptor; + }, + closeSync: (descriptor: number) => { descriptors.delete(descriptor); native.closeSync(descriptor); }, + fsyncSync: (descriptor: number) => { + const path = descriptors.get(descriptor) ?? ""; + const stage = path.startsWith(target + ".ocx.") ? "temp" : path === dirname(target) ? "directory" : null; + if (stage) events.push("fsync:" + stage); + if (stage && failure === stage) throw new Error("fixture fsync failure"); + native.fsyncSync(descriptor); + }, + renameSync: (source: fs.PathLike, destination: fs.PathLike) => { + if (String(destination) === target) events.push("rename"); + native.renameSync(source, destination); + }, +})); +const { atomicWriteFileNoFollowStreamed } = await import("../../src/config/atomic-write"); +let home: TempHome; +beforeEach(() => { + home = createTempHome("ocx-nofollow-streamed-"); + target = home.path("dashboard-sessions.json"); failure = null; events = []; + native.writeFileSync(target, "previous"); +}); +afterEach(() => { failure = null; home.remove(); }); +afterAll(() => { mock.restore(); }); +function publish() { + atomicWriteFileNoFollowStreamed(target, descriptor => native.writeFileSync(descriptor, "replacement")); +} + +test("content fsync precedes publication, followed by best-effort directory fsync", () => { + publish(); + expect(native.readFileSync(target, "utf8")).toBe("replacement"); + expect(events).toEqual(process.platform === "win32" ? ["fsync:temp", "rename"] : ["fsync:temp", "rename", "fsync:directory"]); +}); + +test("temp fsync failure prevents rename and removes the unpublished temporary file", () => { + failure = "temp"; + expect(publish).toThrow("fixture fsync failure"); + expect(events).toEqual(["fsync:temp"]); + expect(native.readFileSync(target, "utf8")).toBe("previous"); + expect(native.readdirSync(home.root).filter(name => name.includes(".ocx.") && name.endsWith(".tmp"))).toEqual([]); +}); + +test.skipIf(process.platform === "win32")("directory fsync failure still reports a successful publication", () => { + failure = "directory"; publish(); + expect(events).toEqual(["fsync:temp", "rename", "fsync:directory"]); + expect(native.readFileSync(target, "utf8")).toBe("replacement"); +}); + +test.skipIf(process.platform === "win32")("a symlinked final entry is replaced without following its target", () => { + const other = home.path("other.json"); native.writeFileSync(other, "untouched"); + native.unlinkSync(target); native.symlinkSync(other, target); + publish(); + expect(native.lstatSync(target).isSymbolicLink()).toBe(false); + expect(native.readFileSync(target, "utf8")).toBe("replacement"); + expect(native.readFileSync(other, "utf8")).toBe("untouched"); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index cd7a0ea616d..1ec22ae727d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1131,6 +1131,8 @@ "server-live-realtime-fixtures.test.ts": "server", "server-live.test.ts": "server", "server-loopback-host-gate.test.ts": "server", "server-management-auth.test.ts": "server", "persistent-dashboard-session.test.ts": "server", + "persistent-dashboard-admission.test.ts": "server", + "atomic-write-nofollow-streamed.test.ts": "config", "server-management-log-admission.test.ts": "server", "server-opencode-go-goal-streaming.test.ts": "server", "server-rate-limit-retry-e2e.test.ts": "server", "server-request-body-size.test.ts": "server", "server-search.test.ts": "server", diff --git a/tests/server/persistent-dashboard-admission.test.ts b/tests/server/persistent-dashboard-admission.test.ts new file mode 100644 index 00000000000..7e763fef198 --- /dev/null +++ b/tests/server/persistent-dashboard-admission.test.ts @@ -0,0 +1,348 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { linkSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import { remoteConfig, hubConfig } from "../helpers/management-auth-fixture"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { ManagementApiDeps } from "../../src/server/management/context"; +import { + createManagementSessionControl, initializeManagementAuthState, issueGuiSession, + managementPrincipal, managementSessionIssuance, requireManagementAuth, +} from "../../src/server/management-auth"; +import { markTrustedHubManagementRequest } from "../../src/server/auth-cors"; +import { serveGuiFile, serveSessionBootstrap } from "../../src/server/gui-static"; +import * as atomic from "../../src/config/atomic-write"; +import * as accountList from "../../src/codex/auth-api/account-list"; +import { invalidateStarStatusCache, setStarDepsForTests } from "../../src/github/star-state"; +import type { OcxConfig } from "../../src/types"; +import { startMachineListener } from "../../src/client/machine-listener"; +import { createDesktopCompatibilityRuntime } from "../../src/codex/desktop-compatibility/runtime"; + +const ORIGIN = "https://dashboard.example.test"; +const ADMIN = "ocx_admin_admission_fixture"; +const PROOF_HEADER = "X-OpenCodex-Device-Proof"; +let home: TempHome; +let config: OcxConfig; +let state: ReturnType; +let previousToken: string | undefined; +const spies: Array<{ mockRestore(): void }> = []; +beforeEach(() => { + home = createTempHome("ocx-dashboard-admission-"); + previousToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = ADMIN; + config = remoteConfig(); + state = initializeManagementAuthState(config); +}); +afterEach(() => { + for (const spy of spies.splice(0)) spy.mockRestore(); + setStarDepsForTests(null); invalidateStarStatusCache(); + if (previousToken === undefined) delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + else process.env.OPENCODEX_ADMIN_AUTH_TOKEN = previousToken; + home.remove(); +}); +function request(path: string, options: { origin?: string; method?: string; headers?: HeadersInit; body?: object; token?: string | null } = {}) { + const origin = options.origin ?? ORIGIN; + const headers = new Headers({ Host: new URL(origin).host, Origin: origin, "User-Agent": "dashboard-admission-fixture" }); + if (options.token !== null) headers.set("X-OpenCodex-API-Key", options.token ?? ADMIN); + new Headers(options.headers).forEach((value, key) => headers.set(key, value)); + return new Request(origin + path, { method: options.method ?? "POST", headers, + ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }) }); +} +async function dispatch(req: Request, deps: ManagementApiDeps = {}, trustedLoopback = false) { + const denial = requireManagementAuth(req, state, config); + if (denial) return denial; + return (await handleManagementAPI(req, new URL(req.url), config, deps, + managementPrincipal(req, state, config)!, createManagementSessionControl(state), { + trustedLoopback, guiSessionIssuance: managementSessionIssuance(req, state), + }))!; +} +async function remember(origin = ORIGIN) { + const response = await dispatch(request("/api/session/remember", { origin })); + expect(response.status).toBe(200); + const body = await response.json(); + return { response, body, cookie: response.headers.get("Set-Cookie")!.split(";")[0]!, origin }; +} +type Device = Awaited>; +function deviceRequest(device: Device, path: string, options: { body?: object; csrf?: boolean; method?: string } = {}) { + return request(path, { origin: device.origin, token: device.body.session.token, method: options.method, + body: options.body, headers: { Cookie: device.cookie, "X-OpenCodex-GUI-Origin": device.origin, + ...(options.csrf === false ? {} : { "X-OpenCodex-CSRF-Token": device.body.session.csrfToken }) } }); +} +function resume(device: Device, proof: string | null = device.body.device.proof, path = "/opencodex-session", extra: HeadersInit = {}) { + return issueGuiSession(request(path, { origin: device.origin, token: null, method: "GET", + headers: { Cookie: device.cookie, ...(proof === null ? {} : { [PROOF_HEADER]: proof }), ...extra } }), config, state); +} + +test("real remember admission requires admin, exact Origin, HTTPS or loopback", async () => { + expect((await dispatch(request("/api/session/remember", { token: null }))).status).toBe(401); + const missingOrigin = request("/api/session/remember"); missingOrigin.headers.delete("Origin"); + expect((await dispatch(missingOrigin)).status).toBe(403); + for (const headers of [{ Origin: "https://other.test" }, { "Sec-Fetch-Site": "cross-site" }, { "Sec-Fetch-Site": "same-site" }]) { + expect((await dispatch(request("/api/session/remember", { headers }))).status).toBe(403); + } + expect((await dispatch(request("/api/session/remember", { origin: "http://dashboard.example.test" }))).status).toBe(403); + const device = await remember(); + expect((await dispatch(deviceRequest(device, "/api/session/remember"))).status).toBe(403); + expect((await remember("http://127.0.0.1:10100")).body.session.issuance).toBe("remembered-admin"); +}); + +test("forwarded headers cannot upgrade plaintext or change the origin binding", async () => { + const headers = { "X-Forwarded-Proto": "https", "X-Forwarded-Host": "dashboard.example.test", "X-Forwarded-For": "127.0.0.1" }; + expect((await dispatch(request("/api/session/remember", { origin: "http://dashboard.example.test", headers }))).status).toBe(403); + const req = request("/api/session/remember", { headers: { ...headers, Host: "other.example.test" } }); + expect((await dispatch(req)).status).toBe(403); + const device = await remember(); + expect(resume(device, device.body.device.proof, "/opencodex-session", headers)?.issuance).toBe("remembered-admin"); +}); + +function hubListenerRequest(path: string, method: string, headers: HeadersInit, token: string | null = ADMIN) { + // The dedicated hub management listener receives plain HTTP from a local TLS terminator. + const req = new Request("http://127.0.0.1:4100" + path, { method, headers: { + Host: "hub.example.test", Origin: "https://hub.example.test", "User-Agent": "dashboard-admission-fixture", + ...(token === null ? {} : { "X-OpenCodex-API-Key": token }), ...Object.fromEntries(new Headers(headers)) } }); + markTrustedHubManagementRequest(req); + return req; +} + +test("the process-marked hub management listener can remember; unmarked and relayed planes cannot", async () => { + config = hubConfig(); state = initializeManagementAuthState(config); + const response = await dispatch(hubListenerRequest("/api/session/remember", "POST", {})); + expect(response.status).toBe(200); + const body = await response.json() as { device: { proof: string } }; + const cookie = response.headers.get("Set-Cookie")!; + expect(cookie.startsWith("__Host-")).toBe(true); + expect(cookie).toContain("Secure"); + const resumed = issueGuiSession(hubListenerRequest("/opencodex-session", "GET", + { Cookie: cookie.split(";")[0]!, [PROOF_HEADER]: body.device.proof }, null), config, state); + expect(resumed?.issuance).toBe("remembered-admin"); + const withoutProof = issueGuiSession(hubListenerRequest("/opencodex-session", "GET", { Cookie: cookie.split(";")[0]! }, null), config, state); + expect(withoutProof?.issuance).not.toBe("remembered-admin"); + // Forwarded headers cannot manufacture the listener mark: an unmarked plain-HTTP request claiming TLS is refused. + const forged = new Request("http://hub.example.test/api/session/remember", { method: "POST", headers: { + Host: "hub.example.test", Origin: "https://hub.example.test", "User-Agent": "dashboard-admission-fixture", + "X-OpenCodex-API-Key": ADMIN, "X-Forwarded-Proto": "https", "X-Forwarded-Host": "hub.example.test" } }); + expect((await dispatch(forged)).status).toBe(403); + config = remoteConfig(); state = initializeManagementAuthState(config); + const relayed = request("/api/session/remember"); markTrustedHubManagementRequest(relayed); + expect((await dispatch(relayed)).status).toBe(403); +}); + +test("all issueGuiSession bootstrap shapes require proof before remembered issuance", async () => { + const device = await remember(); state = initializeManagementAuthState(config); + for (const path of ["/opencodex-session", "/", "/settings"]) { + expect(resume(device, null, path)?.issuance).not.toBe("remembered-admin"); + expect(resume(device, "ocx_proof_" + "x".repeat(43), path)?.issuance).not.toBe("remembered-admin"); + expect(resume(device, device.body.device.proof, path)?.issuance).toBe("remembered-admin"); + } + config.hostname = "127.0.0.1"; + const loopback = await remember("http://127.0.0.1:10100"); + expect(resume(loopback, null)?.issuance).toBe("loopback"); + expect(resume(loopback)?.issuance).toBe("remembered-admin"); + expect(resume(device, device.body.device.proof, "/", { Origin: "https://other.test" })).toBeNull(); + expect(issueGuiSession(request("/opencodex-session", { origin: ORIGIN + ":444", method: "GET", token: null, + headers: { Cookie: device.cookie, [PROOF_HEADER]: device.body.device.proof } }), config, state)).toBeNull(); + expect(resume(device, device.body.device.proof, "/", { Cookie: device.cookie + "; " + device.cookie })).toBeNull(); +}); + +test("the live machine listener checks proof on fetch bootstrap and navigation", async () => { + writeFileSync(home.path("config.json"), JSON.stringify(config)); + const oldDist = process.env.OPENCODEX_GUI_DIST; + mkdirSync(home.path("machine-gui")); + writeFileSync(home.path("machine-gui", "index.html"), ""); + process.env.OPENCODEX_GUI_DIST = home.path("machine-gui"); + const listener = startMachineListener(0, { managementAuthState: state, state: { + serverUrl: "https://hub.example.test", managementUrl: "https://hub.example.test", managementTransport: "direct", + selectedClients: ["codex"], tokenEnv: "OPENCODEX_API_AUTH_TOKEN", apiKeyId: "fixture-client", + tokenFingerprint: "a".repeat(64), protocolVersion: 1, connectedAt: "2026-10-18T00:00:00Z", + catalogSyncedAt: "2026-10-18T00:00:00Z", + } }); + try { + const origin = `http://127.0.0.1:${listener.port}`; + const device = await remember(origin); + for (const path of ["/opencodex-session", "/"]) { + for (const proof of [null, "ocx_proof_" + "x".repeat(43), device.body.device.proof]) { + const response = await fetch(origin + path, { headers: { Cookie: device.cookie, + "User-Agent": "dashboard-admission-fixture", ...(proof === null ? {} : { [PROOF_HEADER]: proof }) } }); + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).not.toContain(device.body.device.proof); + const token = /name="opencodex-session-token" content="([^"]+)"/.exec(html)?.[1]; + expect(token).toBeDefined(); + expect(state.available && state.sessions.get(token!)?.issuance).toBe(proof === device.body.device.proof ? "remembered-admin" : "loopback"); + } + } + } finally { + await listener.stop(true); + if (oldDist === undefined) delete process.env.OPENCODEX_GUI_DIST; + else process.env.OPENCODEX_GUI_DIST = oldDist; + } +}); + +test("proof stays out of cookies, bootstrap HTML, URLs, disk plaintext and logs", async () => { + const logs: string[] = []; + for (const level of ["log", "info", "warn", "error", "debug"] as const) { + spies.push(spyOn(console, level).mockImplementation((...args) => { logs.push(args.map(String).join(" ")); })); + } + const device = await remember(); const proof = device.body.device.proof; + expect(proof).toMatch(/^ocx_proof_[A-Za-z0-9_-]{43}$/); + expect(device.response.headers.get("Cache-Control")).toBe("no-store"); + expect(device.response.headers.get("Set-Cookie")).not.toContain(proof); + const record = JSON.parse(readFileSync(home.path("dashboard-sessions.json"), "utf8"))[0]; + expect(Object.keys(record).sort()).toEqual(["credential", "digest", "expiresAt", "proof", "serverOrigin", "userAgent"]); + expect(record.proof).toBe(createHash("sha256").update(proof).digest("hex")); + expect(JSON.stringify(record)).not.toContain(proof); + const bootstrap = resume(device)!; + expect(await serveSessionBootstrap(bootstrap).text()).not.toContain(proof); + const dist = home.path("gui"); mkdirSync(dist); writeFileSync(home.path("gui", "index.html"), ""); + for (const path of ["/", "/settings"]) { + const navigation = resume(device, null, path); + const html = await serveGuiFile(path, dist, navigation ?? undefined)!.text(); + expect(html).not.toContain(proof); expect(html).not.toContain(device.body.session.token); + } + expect(deviceRequest(device, "/api/config").url).not.toContain(proof); + expect(logs.join("\n")).not.toContain(proof); +}); + +test("logout needs CSRF; successful forget survives a new auth state reading disk", async () => { + const device = await remember(); + expect((await dispatch(deviceRequest(device, "/api/session/logout", { csrf: false }))).status).toBe(401); + expect(resume(device)?.issuance).toBe("remembered-admin"); + const response = await dispatch(deviceRequest(device, "/api/session/logout")); + expect(response.status).toBe(200); expect(response.headers.get("Set-Cookie")).toContain("Max-Age=0"); + expect(JSON.parse(readFileSync(home.path("dashboard-sessions.json"), "utf8"))).toEqual([]); + state = initializeManagementAuthState(config); expect(resume(device)).toBeNull(); +}); + +test("failed durable forget keeps disk records and all derived sessions for retry", async () => { + const device = await remember(); const recovered = resume(device)!; + const before = readFileSync(home.path("dashboard-sessions.json"), "utf8"); + const writer = spyOn(atomic, "atomicWriteFileNoFollowStreamed").mockImplementation(() => { throw new Error("fixture persistence failure"); }); + try { + expect((await dispatch(deviceRequest(device, "/api/session/logout"))).status).toBe(503); + expect(readFileSync(home.path("dashboard-sessions.json"), "utf8")).toBe(before); + expect(state.available && state.sessions.has(recovered.token)).toBe(true); + expect((await dispatch(deviceRequest(device, "/api/config", { method: "GET" }))).status).toBe(200); + } finally { writer.mockRestore(); } + expect((await dispatch(deviceRequest(device, "/api/session/logout"))).status).toBe(200); +}); + +test("admin-token rotation invalidates a stored cookie and proof", async () => { + const device = await remember(); process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "ocx_admin_rotated_fixture"; + state = initializeManagementAuthState(config); expect(resume(device)).toBeNull(); +}); + +test("malformed schema is preserved byte-for-byte and Remember returns 503", async () => { + await remember(); + const record = JSON.parse(readFileSync(home.path("dashboard-sessions.json"), "utf8"))[0]; + const { proof: _proof, ...legacy } = record; + const malformed = ["not-json\n", "{}", '[{"digest":"bad"}]', JSON.stringify([legacy]), + ...[null, "x".repeat(64), "A".repeat(64), "a".repeat(63)].map(proof => JSON.stringify([{ ...record, proof }])), + JSON.stringify([{ ...record, extra: true }])]; + for (const bytes of malformed) { + writeFileSync(home.path("dashboard-sessions.json"), bytes); + expect((await dispatch(request("/api/session/remember"))).status).toBe(503); + expect(readFileSync(home.path("dashboard-sessions.json"), "utf8")).toBe(bytes); + } +}); + +test.skipIf(process.platform === "win32")("symlinked store is refused without changing its target", async () => { + const bytes = "[]"; writeFileSync(home.path("other.json"), bytes); + symlinkSync(home.path("other.json"), home.path("dashboard-sessions.json")); + expect((await dispatch(request("/api/session/remember"))).status).toBe(503); + expect(readFileSync(home.path("other.json"), "utf8")).toBe(bytes); +}); +test.skipIf(process.platform === "win32")("hardlinked store is refused without changing either entry", async () => { + writeFileSync(home.path("other.json"), "[]"); linkSync(home.path("other.json"), home.path("dashboard-sessions.json")); + expect((await dispatch(request("/api/session/remember"))).status).toBe(503); + expect(readFileSync(home.path("other.json"), "utf8")).toBe("[]"); +}); +test.skipIf(process.platform === "win32")("oversized store is preserved", async () => { + const bytes = " ".repeat(128 * 1024 + 1); writeFileSync(home.path("dashboard-sessions.json"), bytes); + expect((await dispatch(request("/api/session/remember"))).status).toBe(503); + expect(readFileSync(home.path("dashboard-sessions.json"), "utf8")).toBe(bytes); +}); + +test("the 128-record limit refuses an additional device without overwriting the store", async () => { + await remember(); const record = JSON.parse(readFileSync(home.path("dashboard-sessions.json"), "utf8"))[0]; + const rows = Array.from({ length: 128 }, (_, i) => ({ ...record, digest: createHash("sha256").update(String(i)).digest("hex") })); + const bytes = JSON.stringify(rows); writeFileSync(home.path("dashboard-sessions.json"), bytes); + expect((await dispatch(request("/api/session/remember"))).status).toBe(503); + expect(readFileSync(home.path("dashboard-sessions.json"), "utf8")).toBe(bytes); +}); + +const guarded = [ + ["/api/github/star", {}, "agent_consent_required"], + ["/api/anthropic/reset-grants/consume", {}, "session_required"], + ["/api/codex-prompt/repair", {}, "dashboard_session_required"], + ["/api/oauth/login", { provider: "meta-muse" }, "oauth_consent_required"], + ["/api/codex/desktop-compatibility/certificate", { action: "prepare", confirmed: true }, "local_dashboard_confirmation_required"], + ["/api/codex/desktop-compatibility/runtime", { action: "observe", confirmed: true }, "local_dashboard_confirmation_required"], + ["/api/codex/desktop-compatibility/settings", { startOnProxyStart: false, revision: "a".repeat(64), confirmed: true }, "local_dashboard_confirmation_required"], +] as const; +test("remembered admission reaches and refuses all seven consent routes even with trusted loopback ingress", async () => { + const device = await remember("http://127.0.0.1:10100"); + for (const [path, body, code] of guarded) { + const response = await dispatch(deviceRequest(device, path, { body }), {}, true); + expect(response.status).toBe(403); + const result = await response.json(); expect(result.code ?? result.error?.code ?? result.error).toBe(code); + } + for (const path of ["/api/oauth/login/code"]) { + const response = await dispatch(deviceRequest(device, path, { body: { provider: "meta-muse" } }), {}, true); + expect(response.status).toBe(403); expect((await response.json()).code).toBe("oauth_consent_required"); + } +}); + +test("ordinary loopback sessions still reach consented sidebar and desktop services", async () => { + config.hostname = "127.0.0.1"; + const origin = "http://127.0.0.1:10100"; + const session = issueGuiSession(request("/", { origin, method: "GET", token: null }), config, state)!; + expect(session.issuance).toBe("loopback"); + const device = { origin, cookie: "", body: { session } } as Device; + let stars = 0; + setStarDepsForTests({ runGh: async () => { stars++; return { status: 0 }; }, nowMs: () => Date.now() }); + const star = await dispatch(deviceRequest(device, "/api/github/star")); + expect(star.status).toBe(200); expect(stars).toBeGreaterThan(0); + const calls: string[] = []; + const status = { supported: true, state: "prepared" as const, busy: null }; + const deps: ManagementApiDeps = { desktopCertificateService: { + status: async () => status, prepare: async () => { calls.push("prepare"); return status; }, + trust: async () => status, removeTrust: async () => status, renew: async () => status, + } }; + expect((await dispatch(deviceRequest(device, guarded[4][0], { body: guarded[4][1] }), deps, true)).status).toBe(200); + expect(calls).toEqual(["prepare"]); + const runtime = createDesktopCompatibilityRuntime({ platform: "linux" }); + runtime.observe = async () => { calls.push("observe"); return runtime.status(); }; + deps.desktopCompatibilityRuntime = runtime; + deps.desktopStartupSettings = { + status: () => ({ startOnProxyStart: false, revision: "a".repeat(64) }), + set: (value, revision) => { calls.push("settings"); return { startOnProxyStart: value, revision }; }, + }; + for (const index of [5, 6] as const) { + expect((await dispatch(deviceRequest(device, guarded[index][0], { body: guarded[index][1] }), deps, true)).status).toBe(200); + } + expect(calls).toEqual(["prepare", "observe", "settings"]); +}); + +test("account refresh validates pending accounts only for ordinary dashboard sessions", async () => { + const validations: unknown[] = []; + spies.push(spyOn(accountList, "listCodexAuthAccounts").mockImplementation(async (_config, _refresh, options) => { + validations.push(options?.validatePending); return []; + })); + const device = await remember("http://127.0.0.1:10100"); + expect((await dispatch(deviceRequest(device, "/api/codex-auth/accounts/refresh"))).status).toBe(200); + expect((await dispatch(request("/api/codex-auth/accounts/refresh"))).status).toBe(200); + config.hostname = "127.0.0.1"; + const session = issueGuiSession(request("/", { origin: device.origin, method: "GET", token: null }), config, state)!; + expect((await dispatch(deviceRequest({ ...device, cookie: "", body: { session } }, "/api/codex-auth/accounts/refresh"))).status).toBe(200); + expect(validations).toEqual([false, false, true]); +}); + +test("remembered sessions cannot pair, reveal data keys, or authorize local links", async () => { + const device = await remember("http://127.0.0.1:10100"); + const req = deviceRequest(device, "/api/keys/reveal", { body: { id: "fixture" } }); + const control = createManagementSessionControl(state); + expect(control.isPaired(req, config)).toBe(false); expect(control.canRevealDataKeys?.(req, config)).toBe(false); + expect((await dispatch(req)).status).toBe(403); + const link = await dispatch(deviceRequest(device, "/api/link/join", { body: {} }), {}, true); + expect(link.status).toBe(403); +}); diff --git a/tests/server/persistent-dashboard-session.test.ts b/tests/server/persistent-dashboard-session.test.ts index 3ffa44b5f64..9d2f01f6107 100644 --- a/tests/server/persistent-dashboard-session.test.ts +++ b/tests/server/persistent-dashboard-session.test.ts @@ -34,26 +34,33 @@ test("remember exchanges the admin token for a device cookie and a server-issued const body = await response!.json(); expect(body.session.token).toStartWith("ocx_session_"); expect(body.session.issuance).toBe("remembered-admin"); + expect(body.device.proof).toMatch(/^ocx_proof_[A-Za-z0-9_-]{43}$/); + expect(response!.headers.get("Cache-Control")).toBe("no-store"); + expect(cookie).not.toContain(body.device.proof); const disk = readFileSync(join(home, "dashboard-sessions.json"), "utf8"); + expect(disk).not.toContain(body.device.proof); + expect(Object.keys(JSON.parse(disk)[0]).sort()).toEqual(["credential", "digest", "expiresAt", "proof", "serverOrigin", "userAgent"]); expect(disk).not.toContain("ocx_admin_fixture"); expect(disk).not.toContain(cookie.split(";")[0]!.split("=")[1]!); }); -test("a device resumes after a server restart, but not on another origin or browser", () => { +test("a device resumes after a server restart, but not on another origin or browser", async () => { const { response } = login(); expect(response?.status).toBe(200); const cookie = response!.headers.get("set-cookie")!.split(";")[0]!; + const proof = (await response!.json()).device.proof; const restarted = initializeManagementAuthState(config); - const resumed = issueGuiSession(request("/opencodex-session", cookie, {}, "GET"), config, restarted); + const resumed = issueGuiSession(request("/opencodex-session", cookie, { "X-OpenCodex-Device-Proof": proof }, "GET"), config, restarted); expect(resumed?.token).toStartWith("ocx_session_"); expect(issueGuiSession(request("/", cookie, { "User-Agent": "another-device" }, "GET"), config, restarted)).toBeNull(); const other = new Request("https://other.example.test/", { headers: { Host: "other.example.test", Cookie: cookie, "User-Agent": "Safari-PWA-fixture" } }); expect(issueGuiSession(other, config, restarted)).toBeNull(); }); -test("expiry is absolute and rotating the admin token invalidates persistent devices", () => { +test("expiry is absolute and rotating the admin token invalidates persistent devices", async () => { const { response } = login(); expect(response?.status).toBe(200); const cookie = response!.headers.get("set-cookie")!.split(";")[0]!; - const req = request("/", cookie, {}, "GET"); + const proof = (await response!.json()).device.proof; + const req = request("/", cookie, { "X-OpenCodex-Device-Proof": proof }, "GET"); expect(issueGuiSession(req, config, initializeManagementAuthState(config), { trustedTailscaleIngress: false, now: Date.now() + 31 * 86400_000 })).toBeNull(); process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "ocx_admin_rotated"; expect(issueGuiSession(req, config, initializeManagementAuthState(config))).toBeNull(); @@ -62,8 +69,9 @@ test("expiry is absolute and rotating the admin token invalidates persistent dev test("logout durably forgets this device and revokes all resumed sessions", async () => { const { state, response } = login(); expect(response?.status).toBe(200); const cookie = response!.headers.get("set-cookie")!.split(";")[0]!; - const resumed = issueGuiSession(request("/", cookie, {}, "GET"), config, state)!; - const session = (await response!.json()).session; + const body = await response!.json(); + const resumed = issueGuiSession(request("/", cookie, { "X-OpenCodex-Device-Proof": body.device.proof }, "GET"), config, state)!; + const session = body.session; const req = request("/api/session/logout", cookie, { "X-OpenCodex-API-Key": session.token, "X-OpenCodex-GUI-Origin": origin, "X-OpenCodex-CSRF-Token": session.csrfToken }); const result = handleSessionRoutes({ req, url: new URL(req.url), config, principal: "gui-session", sessionControl: createManagementSessionControl(state) } as ManagementContext); expect(result?.status).toBe(200); expect(result!.headers.get("set-cookie")).toContain("Max-Age=0"); From 985df02dd89ab3e1422d9a0b51781bb417bd43ee Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 18 Oct 2026 04:44:25 +0900 Subject: [PATCH 07/10] feat(dashboard): persist and send the remembered-device proof The dashboard stores the proof returned by Remember in this origin's localStorage, sends it only on the session re-bootstrap request, removes it after a successful logout or Forget, keeps it after a failed logout so retry works, and reports Remember unavailable when storage cannot hold it. Guides in all locales and the structure docs describe the cookie + proof binding, the consent limits of remembered sessions, and best-effort crash durability. --- .../content/docs/fr/guides/web-dashboard.md | 6 +- .../src/content/docs/guides/web-dashboard.md | 6 +- .../content/docs/ja/guides/web-dashboard.md | 6 +- .../content/docs/ko/guides/web-dashboard.md | 6 +- .../content/docs/ru/guides/web-dashboard.md | 6 +- .../content/docs/tr/guides/web-dashboard.md | 6 +- .../docs/zh-cn/guides/web-dashboard.md | 6 +- .../docs/zh-tw/guides/web-dashboard.md | 6 +- gui/src/api.ts | 22 +++- gui/tests/api-auth-memory.test.ts | 12 +- gui/tests/persistent-dashboard.test.ts | 122 +++++++++++++++++- structure/dashboard-and-usage.md | 2 +- structure/gui-and-management-api.md | 2 +- 13 files changed, 190 insertions(+), 18 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/web-dashboard.md b/docs-site/src/content/docs/fr/guides/web-dashboard.md index 3419dbbabe6..9e38ac41af4 100644 --- a/docs-site/src/content/docs/fr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md @@ -37,7 +37,11 @@ remplissage automatique. Le tableau de bord lui-même ne conserve le jeton qu'en dans `localStorage` ni dans `sessionStorage` ; son enregistrement dépend entièrement du navigateur ou du gestionnaire de mots de passe. -**Mémoriser cet appareil** est désactivé par défaut. Sur un tableau de bord HTTPS direct (ou en boucle locale), le serveur échange le jeton administrateur contre un cookie HttpOnly, SameSite=Strict, limité à cet hôte et Secure en HTTPS. Aucun jeton administrateur n’est stocké dans localStorage ou sessionStorage. Le fichier privé dashboard-sessions.json conserve uniquement les empreintes du cookie, du jeton et du navigateur ainsi qu’une expiration fixe de 30 jours. La relance de l’application Safari reprend une session courte tant que cet appareil reste autorisé. **Oublier cet appareil** dans les actions mobiles ou la barre latérale, ainsi que la déconnexion, révoque cet appareil et toutes ses sessions. L’expiration, un changement d’identité du navigateur ou la rotation du jeton suivie d’un redémarrage impose une nouvelle connexion. Les tableaux de bord relayés, inter-origines ou HTTP distant ne prennent pas en charge cette option. Les anciennes clés de jeton mémorisé sont supprimées sans lecture ni migration. Réservez-la aux appareils de confiance ; elle ne rétablit pas l’AutoFill de Safari. +**Mémoriser cet appareil** est désactivé par défaut. Sur un tableau de bord HTTPS direct (ou en boucle locale), le serveur échange le jeton administrateur contre un cookie d’appareil HttpOnly, SameSite=Strict et une preuve distincte stockée dans le localStorage de ce site. Le cookie est Secure en HTTPS et limité à l’hôte. Ni le cookie ni la preuve ne sont le jeton administrateur, et aucun ne fonctionne seul. Les cookies sont partagés entre les ports d’un même nom d’hôte ; la preuve, limitée à l’origine, lie l’appareil mémorisé à l’origine de ce tableau de bord. Préférez tout de même un nom d’hôte dédié. Le jeton administrateur n’est jamais écrit dans localStorage ou sessionStorage. Si le stockage du navigateur est indisponible ou ne peut enregistrer la preuve, la mémorisation est signalée comme indisponible. + +Le fichier privé dashboard-sessions.json conserve les empreintes du cookie, de la preuve, du jeton administrateur et de l’identité du navigateur, l’origine du serveur et une expiration fixe de 30 jours. La relance de l’application Safari reprend une session courte tant que cet appareil reste autorisé. Les sessions mémorisées ne peuvent pas approuver les actions de consentement : étoile GitHub, consommation de crédits de réinitialisation, modification des couches de prompt, connexion Meta Muse, changements de compatibilité du bureau ou validation des quotas. Utilisez une nouvelle session locale ou appairée pour ces actions. Les tableaux de bord relayés, inter-origines ou HTTP distant ne prennent pas en charge la mémorisation. L’expiration, un changement d’identité du navigateur ou la rotation du jeton suivie d’un redémarrage imposent une nouvelle connexion. + +Une action **Oublier cet appareil** réussie, dans la barre latérale ou les actions mobiles, ainsi que la déconnexion réussie, révoque immédiatement l’enregistrement et toutes ses sessions et supprime la preuve du navigateur. Un échec de déconnexion conserve la preuve pour réessayer. Le serveur synchronise le contenu avant de publier le changement ; la persistance de l’entrée de répertoire après un plantage est assurée au mieux. Les anciennes clés de jeton mémorisé sont supprimées sans lecture ni migration. Réservez cette option aux appareils de confiance ; elle ne rétablit pas l’AutoFill de Safari. ## Barre de résumé des quotas diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index f21b0f44f78..138a604bdf2 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -35,7 +35,11 @@ password manager can offer to save and autofill it. The dashboard itself still k in memory and does not write it to `localStorage` or `sessionStorage`; whether it is saved is entirely the browser or password manager's decision. -**Remember this device** is off by default. On a direct HTTPS dashboard (or local loopback), it exchanges the admin token for a server-issued HttpOnly, SameSite=Strict device cookie. HTTPS cookies are Secure and host-only. The browser never stores the admin token in localStorage or sessionStorage. The server stores only a cookie hash, an admin-credential fingerprint, browser identity hash and a fixed 30-day expiry in its private dashboard-sessions.json file. Relaunching the Safari home-screen app resumes a short-lived GUI session while that device record is valid. **Forget this device** in the sidebar or mobile action bar (and session logout) revokes the record and all sessions resumed from it. Expiry, a browser identity change, or rotating the admin token and restarting the server requires sign-in again. Cross-origin/relayed dashboards and remote plaintext HTTP do not support this option. Older remembered bearer-token keys are removed without being read or migrated. Use only on a device you trust. This does not make Safari offer password AutoFill. +**Remember this device** is off by default. On a direct HTTPS dashboard (or local loopback), it exchanges the admin token for a server-issued HttpOnly, SameSite=Strict device cookie plus a separate device proof stored in this site's localStorage. HTTPS cookies are Secure and host-only. Neither the cookie nor the proof is the admin token, and neither works alone. Cookies are shared across ports of the same hostname; the origin-scoped proof binds the remembered device to this dashboard origin. Still prefer a dedicated hostname. The admin token is never written to localStorage or sessionStorage. If browser storage is unavailable or cannot save the proof, Remember reports unavailable. + +The private dashboard-sessions.json stores hashes of the cookie, proof, admin credential and browser identity, the server origin and a fixed 30-day expiry. Relaunching the Safari home-screen app resumes a short-lived GUI session while that device record is valid. Remembered sessions cannot approve consent actions: GitHub stars, reset-credit consumption, prompt-layer edits, Meta Muse sign-in, desktop compatibility changes or quota validation. Use a fresh local or paired session for those actions. Cross-origin/relayed dashboards and remote plaintext HTTP do not support Remember. Expiry, a browser identity change, or rotating the admin token and restarting the server requires sign-in again. + +Successful **Forget this device** in the sidebar or mobile action bar (and session logout) immediately revokes the record and all sessions resumed from it, and removes the browser proof. Failed logout keeps the proof so you can retry. The server syncs the record contents before publishing the change; directory-entry durability after a crash is best effort. Older remembered bearer-token keys are removed without being read or migrated. Use only on a device you trust. This does not make Safari offer password AutoFill. ### Finding the admin token diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 3b59f7d7a4c..fb2c52efe69 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -27,7 +27,11 @@ bun run dev:gui リモートダッシュボードでは標準のパスワードフォームが表示され、ブラウザのパスワードマネージャーで保存・自動入力できます。ダッシュボード自体はトークンをメモリ内だけに保持し、`localStorage` や `sessionStorage` には書き込みません。保存するかどうかはブラウザまたはパスワードマネージャーだけが決定します。 -**このデバイスを記憶する** は既定でオフです。直接アクセスする HTTPS ダッシュボード(またはローカルループバック)で、管理者トークンをサーバー発行の HttpOnly・SameSite=Strict Cookie に交換します。HTTPS では Secure とホスト限定が適用されます。管理者トークンを localStorage や sessionStorage に保存しません。非公開の dashboard-sessions.json には Cookie・認証情報・ブラウザーのハッシュと固定30日間の有効期限だけを保存します。Safari ホーム画面アプリを再起動すると、有効なデバイス記録から短期セッションを再開します。サイドバーやモバイル操作の **このデバイスを解除**、またはログアウトで記録と派生セッションを失効させます。期限切れ、ブラウザー識別の変更、管理者トークン変更後のサーバー再起動では再ログインが必要です。中継・別オリジン・リモート HTTP では使えません。旧トークン保存キーは読み取りや移行をせず削除します。信頼できるデバイスのみで使用してください。Safari の自動入力を有効にするものではありません。 +**このデバイスを記憶する** は既定でオフです。直接アクセスする HTTPS ダッシュボード(またはローカルループバック)で、管理者トークンをサーバー発行の HttpOnly・SameSite=Strict デバイス Cookie と、このサイトの localStorage に保存する別のデバイス証明に交換します。HTTPS の Cookie は Secure でホスト限定です。Cookie も証明も管理者トークンではなく、どちらも単独では機能しません。同じホスト名のポート間では Cookie が共有されるため、オリジン単位の証明が記憶したデバイスをこのダッシュボードのオリジンに結び付けます。それでも専用ホスト名を推奨します。管理者トークンは localStorage や sessionStorage に保存しません。ブラウザーのストレージが使えない、または証明を保存できない場合、記憶機能は利用不可と表示されます。 + +非公開の dashboard-sessions.json は Cookie・証明・管理者認証情報・ブラウザー識別のハッシュ、サーバーのオリジンと固定30日間の有効期限を保存します。Safari ホーム画面アプリを再起動すると、有効なデバイス記録から短期セッションを再開します。記憶したセッションでは、GitHub スター、リセットクレジットの消費、プロンプト層の編集、Meta Muse サインイン、デスクトップ互換性の変更、クォータ検証などの同意操作を承認できません。これらには新しいローカルまたはペアリング済みセッションを使ってください。中継・別オリジン・リモートの平文 HTTP は記憶機能に対応しません。期限切れ、ブラウザー識別の変更、管理者トークン変更後のサーバー再起動では再ログインが必要です。 + +サイドバーやモバイル操作の **このデバイスを解除**、またはログアウトが成功すると、記録とすべての派生セッションが即座に失効し、ブラウザーの証明を削除します。ログアウトに失敗した場合は再試行のため証明を保持します。サーバーは変更公開前に記録の内容を同期しますが、クラッシュ時のディレクトリエントリの永続性はベストエフォートです。旧トークン保存キーは読み取りや移行をせず削除します。信頼できるデバイスのみで使用してください。Safari の自動入力を有効にするものではありません。 ## クォータ概要バー 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 585aa97ed38..95a834ec27e 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -27,7 +27,11 @@ bun run dev:gui 원격 대시보드는 표준 비밀번호 폼을 표시하므로 브라우저 비밀번호 관리자가 토큰 저장과 자동 완성을 제안할 수 있습니다. 대시보드 자체는 토큰을 메모리에만 보관하며 `localStorage`나 `sessionStorage`에 쓰지 않습니다. 저장 여부는 전적으로 브라우저 또는 비밀번호 관리자가 결정합니다. -**이 기기 기억하기**는 기본적으로 꺼져 있습니다. 직접 HTTPS 대시보드(또는 로컬 루프백)에서 관리자 토큰을 서버 발급 HttpOnly, SameSite=Strict 쿠키로 교환합니다. HTTPS 쿠키는 Secure이며 해당 호스트에만 적용됩니다. 관리자 토큰은 localStorage나 sessionStorage에 저장하지 않습니다. 비공개 dashboard-sessions.json에는 쿠키·인증 정보·브라우저 해시와 고정 30일 만료만 저장합니다. Safari 홈 화면 앱을 다시 열면 유효한 기기 기록으로 단기 세션을 재개합니다. 사이드바나 모바일 작업의 **이 기기 잊기** 및 로그아웃은 기기 기록과 모든 파생 세션을 취소합니다. 만료, 브라우저 식별 변경, 토큰 교체 후 서버 재시작 시 다시 로그인해야 합니다. 릴레이·다른 출처·원격 HTTP에서는 지원하지 않습니다. 이전 토큰 저장 키는 읽거나 마이그레이션하지 않고 삭제합니다. 신뢰하는 기기에서만 사용하세요. Safari 자동 완성을 복원하는 기능은 아닙니다. +**이 기기 기억하기**는 기본적으로 꺼져 있습니다. 직접 HTTPS 대시보드(또는 로컬 루프백)에서 관리자 토큰을 서버 발급 HttpOnly, SameSite=Strict 기기 쿠키와 이 사이트의 localStorage에 저장하는 별도의 기기 증명으로 교환합니다. HTTPS 쿠키는 Secure이며 해당 호스트에만 적용됩니다. 쿠키와 증명은 모두 관리자 토큰이 아니며, 어느 쪽도 단독으로 작동하지 않습니다. 같은 호스트명의 여러 포트는 쿠키를 공유하므로, 오리진별 저장소의 증명이 기억한 기기를 이 대시보드 오리진에 묶습니다. 그래도 전용 호스트명을 권장합니다. 관리자 토큰은 localStorage나 sessionStorage에 저장하지 않습니다. 브라우저 저장소를 사용할 수 없거나 증명 저장에 실패하면 기억하기는 사용 불가로 표시됩니다. + +비공개 dashboard-sessions.json에는 쿠키·증명·관리자 인증 정보·브라우저 식별 해시, 서버 오리진과 고정 30일 만료를 저장합니다. Safari 홈 화면 앱을 다시 열면 유효한 기기 기록으로 단기 세션을 재개합니다. 기억한 세션으로는 GitHub 스타, 리셋 크레딧 소비, 프롬프트 계층 편집, Meta Muse 로그인, 데스크톱 호환성 변경, 할당량 검증에 동의할 수 없습니다. 이 작업에는 새 로컬 세션이나 페어링한 세션을 사용하세요. 릴레이·다른 오리진·원격 평문 HTTP에서는 기억하기를 지원하지 않습니다. 만료, 브라우저 식별 변경, 관리자 토큰 교체 후 서버 재시작 시 다시 로그인해야 합니다. + +사이드바나 모바일 작업의 **이 기기 잊기** 또는 로그아웃이 성공하면 기기 기록과 모든 파생 세션을 즉시 철회하고 브라우저의 증명을 삭제합니다. 로그아웃 실패 시에는 재시도할 수 있도록 증명을 유지합니다. 서버는 변경을 게시하기 전에 기록 내용을 디스크에 동기화하지만, 충돌 후 디렉터리 항목의 내구성은 최선 노력 수준입니다. 이전 토큰 저장 키는 읽거나 마이그레이션하지 않고 삭제합니다. 신뢰하는 기기에서만 사용하세요. Safari 자동 완성을 복원하는 기능은 아닙니다. ## 사용량 요약 바 diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index db5fb56f899..a54851471a3 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -27,7 +27,11 @@ bun run dev:gui Удалённый дашборд показывает стандартную форму пароля, поэтому менеджер паролей браузера может предложить сохранить и автозаполнять токен. Сам дашборд хранит токен только в памяти и не записывает его в `localStorage` или `sessionStorage`; решение о сохранении полностью остаётся за браузером или менеджером паролей. -**Запомнить это устройство** по умолчанию выключено. При прямом HTTPS-доступе (или локальном loopback) сервер заменяет токен администратора на HttpOnly, SameSite=Strict cookie устройства; в HTTPS cookie имеет Secure и привязку к хосту. Токен не записывается в localStorage или sessionStorage. Приватный dashboard-sessions.json хранит только хеши cookie, учётных данных и браузера и фиксированный срок 30 дней. Повторный запуск приложения Safari возобновляет короткую сессию, пока запись действительна. **Забыть это устройство** в боковой панели или мобильных действиях, как и выход, отзывает запись и все связанные сессии. Истечение срока, смена идентичности браузера или смена токена с перезапуском сервера требуют нового входа. Релейный, междоменный и удалённый HTTP-доступ не поддерживаются. Старые ключи сохранённых токенов удаляются без чтения или миграции. Используйте только доверенное устройство; AutoFill Safari это не восстанавливает. +**Запомнить это устройство** по умолчанию выключено. При прямом HTTPS-доступе (или локальном loopback) сервер обменивает токен администратора на cookie устройства HttpOnly, SameSite=Strict и отдельное доказательство устройства в localStorage этого сайта. В HTTPS cookie имеет Secure и привязку к хосту. Ни cookie, ни доказательство не являются токеном администратора и не работают по отдельности. Cookie общие для портов одного имени хоста; доказательство в хранилище конкретного origin привязывает запомненное устройство к origin этой панели. Всё же предпочтительно отдельное имя хоста. Токен администратора не записывается в localStorage или sessionStorage. Если хранилище браузера недоступно или доказательство нельзя сохранить, запоминание сообщает о недоступности. + +Приватный dashboard-sessions.json хранит хеши cookie, доказательства, учётных данных администратора и идентичности браузера, origin сервера и фиксированный срок 30 дней. Повторный запуск приложения Safari возобновляет короткую сессию, пока запись действительна. Запомненные сессии не могут подтверждать действия, требующие согласия: звезду GitHub, расход кредитов сброса, изменение слоёв промпта, вход Meta Muse, изменения совместимости настольного приложения или проверку квот. Для них используйте новую локальную или сопряжённую сессию. Релейный доступ, доступ с другого origin и удалённый HTTP без шифрования не поддерживают запоминание. Истечение срока, смена идентичности браузера или смена токена с перезапуском сервера требуют нового входа. + +Успешное **Забыть это устройство** в боковой панели или мобильных действиях, как и успешный выход, немедленно отзывает запись и все связанные сессии и удаляет доказательство из браузера. При неудачном выходе доказательство сохраняется для повторной попытки. Сервер синхронизирует содержимое записи перед публикацией изменения; сохранность записи каталога при сбое обеспечивается по возможности. Старые ключи сохранённых токенов удаляются без чтения или миграции. Используйте только доверенное устройство; AutoFill Safari это не восстанавливает. ## Полоса сводки квот diff --git a/docs-site/src/content/docs/tr/guides/web-dashboard.md b/docs-site/src/content/docs/tr/guides/web-dashboard.md index 619890f7569..e1ade6b0a5c 100644 --- a/docs-site/src/content/docs/tr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md @@ -39,7 +39,11 @@ yalnızca bellekte tutar ve `localStorage` veya `sessionStorage`'a yazmaz; kaydedilip kaydedilmeyeceği tamamen tarayıcının veya şifre yöneticisinin kararıdır. -**Bu cihazı hatırla** varsayılan olarak kapalıdır. Doğrudan HTTPS panelinde (veya yerel loopback üzerinden) yönetici belirteci, sunucunun verdiği HttpOnly, SameSite=Strict cihaz çereziyle değiştirilir; HTTPS çerezi Secure ve yalnızca bu sunucuya özeldir. Yönetici belirteci localStorage veya sessionStorage içine yazılmaz. Özel dashboard-sessions.json yalnızca çerez, kimlik bilgisi ve tarayıcı özetleriyle sabit 30 günlük süreyi saklar. Safari ana ekran uygulaması yeniden açılınca geçerli cihaz kaydı kısa bir oturumu yeniler. Kenar çubuğu veya mobil işlemlerde **Bu cihazı unut** ve oturumu kapatma, kaydı ve türetilen tüm oturumları iptal eder. Süre dolması, tarayıcı kimliği değişmesi veya belirtecin değiştirilip sunucunun yeniden başlatılması yeniden giriş gerektirir. Relay, başka origin ve uzak düz HTTP desteklenmez. Eski belirteç anahtarları okunmadan veya taşınmadan silinir. Yalnızca güvenilir cihazda kullanın; Safari AutoFill özelliğini geri getirmez. +**Bu cihazı hatırla** varsayılan olarak kapalıdır. Doğrudan HTTPS panelinde (veya yerel loopback üzerinden) yönetici belirteci, sunucunun verdiği HttpOnly, SameSite=Strict cihaz çerezi ve bu sitenin localStorage alanında saklanan ayrı bir cihaz kanıtıyla değiştirilir. HTTPS çerezi Secure ve ana makineyle sınırlıdır. Çerez de kanıt da yönetici belirteci değildir; hiçbiri tek başına çalışmaz. Aynı ana makine adının portları çerezleri paylaşır; origin kapsamındaki kanıt, hatırlanan cihazı bu panelin origin’ine bağlar. Yine de ayrı bir ana makine adı tercih edin. Yönetici belirteci localStorage veya sessionStorage içine yazılmaz. Tarayıcı depolaması kullanılamıyorsa veya kanıt kaydedilemiyorsa Hatırla işlemi kullanılamadığını bildirir. + +Özel dashboard-sessions.json çerezin, kanıtın, yönetici kimlik bilgisinin ve tarayıcı kimliğinin özetlerini, sunucu origin’ini ve sabit 30 günlük süreyi saklar. Safari ana ekran uygulaması yeniden açılınca geçerli cihaz kaydı kısa bir oturumu yeniler. Hatırlanan oturumlar onay gerektiren işlemleri yetkilendiremez: GitHub yıldızı, sıfırlama kredisi tüketimi, istem katmanı düzenlemeleri, Meta Muse girişi, masaüstü uyumluluk değişiklikleri veya kota doğrulaması. Bunlar için yeni bir yerel veya eşleştirilmiş oturum kullanın. Relay, başka origin ve uzak düz HTTP Hatırla seçeneğini desteklemez. Süre dolması, tarayıcı kimliği değişmesi veya belirtecin değiştirilip sunucunun yeniden başlatılması yeniden giriş gerektirir. + +Kenar çubuğu veya mobil işlemlerde başarılı **Bu cihazı unut** ya da başarılı çıkış, kaydı ve türetilen tüm oturumları hemen iptal eder ve tarayıcı kanıtını siler. Başarısız çıkışta yeniden deneme için kanıt korunur. Sunucu değişikliği yayımlamadan önce kayıt içeriğini diske eşitler; çökme sonrası dizin girdisinin kalıcılığı mümkün olduğunca sağlanır. Eski belirteç anahtarları okunmadan veya taşınmadan silinir. Yalnızca güvenilir cihazda kullanın; Safari AutoFill özelliğini geri getirmez. ## Kota özeti çubuğu diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 6a1a39d5e9e..f8b52dcd77d 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -26,7 +26,11 @@ bun run dev:gui 远程仪表盘会显示标准密码表单,浏览器密码管理器可以提示保存并自动填充 token。仪表盘本身只在内存中保存 token,不会写入 `localStorage` 或 `sessionStorage`;是否持久保存完全由浏览器或密码管理器决定。 -**记住此设备** 默认关闭。直接 HTTPS 仪表板(或本地回环)会将管理员令牌交换为服务器签发的 HttpOnly、SameSite=Strict 设备 Cookie;HTTPS 下启用 Secure,且仅限本主机。管理员令牌不存入 localStorage 或 sessionStorage。私有 dashboard-sessions.json 仅保存 Cookie、凭据和浏览器哈希及固定30天有效期。重新打开 Safari 主屏幕应用时,有效设备记录可恢复短期会话。侧栏或移动操作中的 **忘记此设备** 以及退出登录,会撤销该记录及其所有会话。过期、浏览器标识改变,或更换管理员令牌并重启服务器后需重新登录。中继、跨来源和远程明文 HTTP 不支持此选项。旧令牌存储键会直接删除,不读取或迁移。仅在可信设备启用;不会恢复 Safari 密码自动填充。 +**记住此设备** 默认关闭。直接 HTTPS 仪表板(或本地回环)会将管理员令牌交换为服务器签发的 HttpOnly、SameSite=Strict 设备 Cookie,以及单独存入本站 localStorage 的设备证明。HTTPS 下 Cookie 启用 Secure 且仅限本主机。Cookie 和证明都不是管理员令牌,且都不能单独使用。同一主机名的不同端口共享 Cookie;按来源隔离的证明将记住的设备绑定到本仪表板来源。仍建议使用专用主机名。管理员令牌不存入 localStorage 或 sessionStorage。浏览器存储不可用或无法保存证明时,记住设备会报告不可用。 + +私有 dashboard-sessions.json 保存 Cookie、证明、管理员凭据和浏览器标识的哈希、服务器来源及固定30天有效期。重新打开 Safari 主屏幕应用时,有效设备记录可恢复短期会话。记住的会话不能批准需要同意的操作:GitHub 星标、重置额度消费、提示词层编辑、Meta Muse 登录、桌面兼容性更改或配额验证。这些操作请使用新的本地会话或已配对会话。中继、跨来源和远程明文 HTTP 不支持记住设备。过期、浏览器标识改变,或更换管理员令牌并重启服务器后需重新登录。 + +侧栏或移动操作中的 **忘记此设备** 以及退出登录成功后,会立即撤销该记录及其所有派生会话,并删除浏览器证明。退出失败时保留证明以便重试。服务器在发布更改前将记录内容同步到磁盘;崩溃后目录项的持久性为尽力保证。旧令牌存储键会直接删除,不读取或迁移。仅在可信设备启用;不会恢复 Safari 密码自动填充。 ## 配额摘要栏 diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md index 5f2230ebe62..98856c8f988 100644 --- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md @@ -31,7 +31,11 @@ GUI session 簽發到服務的頁面中,並在到期或代理重啟時靜默 儀表板本身仍然只在記憶體中保留 token,不會寫入 `localStorage` 或 `sessionStorage`;是否儲存完全 由瀏覽器或密碼管理員決定。 -**記住此裝置** 預設關閉。直接 HTTPS 儀表板(或本機回環)會將管理員權杖交換為伺服器簽發的 HttpOnly、SameSite=Strict 裝置 Cookie;HTTPS 下使用 Secure,且僅限本主機。管理員權杖不存入 localStorage 或 sessionStorage。私有 dashboard-sessions.json 僅保存 Cookie、憑證和瀏覽器雜湊及固定30天有效期。重新開啟 Safari 主畫面應用程式時,有效裝置記錄可恢復短期工作階段。側欄或行動操作中的 **忘記此裝置** 以及登出,會撤銷記錄及其所有工作階段。過期、瀏覽器識別變更,或更換管理員權杖並重新啟動伺服器後需重新登入。中繼、跨來源和遠端明文 HTTP 不支援此選項。舊權杖儲存鍵會直接刪除,不讀取或遷移。僅在可信裝置啟用;不會恢復 Safari 密碼自動填入。 +**記住此裝置** 預設關閉。直接 HTTPS 儀表板(或本機回環)會將管理員權杖交換為伺服器簽發的 HttpOnly、SameSite=Strict 裝置 Cookie,以及另外存入本站 localStorage 的裝置證明。HTTPS 下 Cookie 使用 Secure,且僅限本主機。Cookie 和證明都不是管理員權杖,且都不能單獨使用。同一主機名稱的不同連接埠共用 Cookie;依來源隔離的證明將記住的裝置綁定至本儀表板來源。仍建議使用專用主機名稱。管理員權杖不存入 localStorage 或 sessionStorage。瀏覽器儲存空間無法使用或無法儲存證明時,記住裝置會回報無法使用。 + +私有 dashboard-sessions.json 儲存 Cookie、證明、管理員憑證和瀏覽器識別的雜湊、伺服器來源及固定30天有效期。重新開啟 Safari 主畫面應用程式時,有效裝置記錄可恢復短期工作階段。記住的工作階段無法核准需要同意的操作:GitHub 星號、重設額度消耗、提示詞層編輯、Meta Muse 登入、桌面相容性變更或配額驗證。這些操作請使用新的本機工作階段或已配對工作階段。中繼、跨來源和遠端明文 HTTP 不支援記住裝置。過期、瀏覽器識別變更,或更換管理員權杖並重新啟動伺服器後需重新登入。 + +側欄或行動操作中的 **忘記此裝置** 以及登出成功後,會立即撤銷記錄及所有衍生工作階段,並刪除瀏覽器證明。登出失敗時保留證明以便重試。伺服器會在發布變更前將記錄內容同步至磁碟;當機後目錄項目的持久性採盡力保證。舊權杖儲存鍵會直接刪除,不讀取或移轉。僅在可信裝置啟用;不會恢復 Safari 密碼自動填入。 ## 配額摘要列 diff --git a/gui/src/api.ts b/gui/src/api.ts index c259b486a34..2e0be7a358e 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -11,6 +11,8 @@ export const SESSION_UNAVAILABLE_EVENT = "opencodex:session-unavailable"; export const SESSION_CHANGED_EVENT = "opencodex:session-changed"; const LEGACY_TOKEN_KEY = "opencodex-api-token"; +const DEVICE_PROOF_KEY = "opencodex.dashboard-device-proof"; +const DEVICE_PROOF_PATTERN = /^ocx_proof_[A-Za-z0-9_-]{43}$/; // Any guarded route answers 401 for a bad token; this one is a cheap config read. /api/settings // also resolves the Codex runtime and startup health, which made the token prompt hang. const ADMIN_TOKEN_VALIDATION_PATH = "/api/combos"; @@ -192,6 +194,9 @@ export async function logoutApiSession(plane: ApiPlane): Promise { if (!response.ok) return false; barrier.completed = true; if (runtimes.get(plane) === state && state.logoutBarrier === barrier) { + if (canRememberDashboardDevice(plane)) { + try { window.localStorage.removeItem(DEVICE_PROOF_KEY); } catch { /* storage may be disabled */ } + } updateSession(state, blankSession()); state.promptCancelled = false; } @@ -375,7 +380,14 @@ async function reBootstrapSessionToken(recovery: RecoveryAttempt, caller?: Abort const send = rawFetch; try { return await withFetchDeadline(deadlineMs, caller, async signal => { - const [input, init] = withAuth(plane, state.target.bootstrapPath, { cache: "no-store", signal }, null); + const headers = new Headers(); + if (canRememberDashboardDevice(plane) && new URL(state.target.bootstrapPath, window.location.href).origin === window.location.origin) { + try { + const proof = window.localStorage.getItem(DEVICE_PROOF_KEY); + if (proof && DEVICE_PROOF_PATTERN.test(proof)) headers.set("X-OpenCodex-Device-Proof", proof); + } catch { /* ordinary session bootstrap still works without browser storage */ } + } + const [input, init] = withAuth(plane, state.target.bootstrapPath, { cache: "no-store", redirect: "error", signal, headers }, null); const response = await send(input, init); if (signal.aborted || !isCurrentRecovery(recovery)) return { kind: "failed" }; if (!response.ok) return response.status >= 400 && response.status < 500 ? { kind: "unavailable" } : { kind: "failed" }; @@ -412,6 +424,14 @@ async function verifyAdminToken(recovery: RecoveryAttempt, token: string, rememb const body = await response.json(); if (signal.aborted || !isCurrentRecovery(recovery) || !storeSession(plane, body?.session?.token, body?.session?.csrfToken, body?.session?.browserOrigin, body?.session?.serverOrigin, recovery)) return "unavailable"; + try { + const proof = body?.device?.proof; + if (typeof proof !== "string" || !DEVICE_PROOF_PATTERN.test(proof)) throw new Error("Invalid device proof"); + window.localStorage.setItem(DEVICE_PROOF_KEY, proof); + } catch { + invalidateSession(state, recovery); + return "unavailable"; + } } return "accepted"; }); diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index 9645ec80593..ffe2cf42176 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; -import { configureApiTargets, fetchAudioUpload, installApiAuthFetch, installApiSessionFromHtml, resetApiAuthFetchForTests } from "../src/api"; +import { bootstrapApiSession, configureApiTargets, fetchAudioUpload, installApiAuthFetch, installApiSessionFromHtml, resetApiAuthFetchForTests } from "../src/api"; import { targetsFromMachineStatus, type MachineStatusV1 } from "../src/api-targets"; const LEGACY_TOKEN_KEY = "opencodex-api-token"; @@ -511,6 +511,7 @@ test("a session minted for another origin is rejected and the prompt fallback st }); test("a renewed two-origin session attaches only to its bound server and carries browser origin plus CSRF", async () => { + window.localStorage.setItem("opencodex.dashboard-device-proof", `ocx_proof_${"A".repeat(43)}`); injectSessionMeta("ocx_session_stale", "stale-csrf", "http://localhost"); const status: MachineStatusV1 = { mode: "client", connected: true, machineBase: "http://localhost", @@ -530,6 +531,7 @@ test("a renewed two-origin session attaches only to its bound server and carries const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost/"); const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); if (url.origin === "https://hub.example.test" && url.pathname === "/opencodex-session") { + expect(headers.has("X-OpenCodex-Device-Proof")).toBe(false); return htmlResponseAt( sessionDocumentHtml("ocx_session_remote", "remote-csrf", "http://localhost", "https://hub.example.test"), "https://hub.example.test/opencodex-session", @@ -555,9 +557,14 @@ test("a renewed two-origin session attaches only to its bound server and carries const evilHeaders = seen.get("https://evil.example.test")?.[0]; expect(evilHeaders?.get("X-OpenCodex-API-Key")).toBeNull(); expect(evilHeaders?.get("X-OpenCodex-GUI-Origin")).toBeNull(); + for (const entries of seen.values()) for (const headers of entries) expect(headers.has("X-OpenCodex-Device-Proof")).toBe(false); }); test("relay requests carry independent shared and machine sessions without cross-target leakage", async () => { + window.localStorage.setItem("opencodex.dashboard-device-proof", `ocx_proof_${"A".repeat(43)}`); + let proofReads = 0; + const storageGet = window.localStorage.getItem.bind(window.localStorage); + Object.defineProperty(window.localStorage, "getItem", { configurable: true, value: (key: string) => { if (key === "opencodex.dashboard-device-proof") proofReads++; return storageGet(key); } }); injectSessionMeta("ocx_session_machine", "machine-csrf", "http://localhost"); const seen = new Map(); const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { @@ -573,6 +580,7 @@ test("relay requests carry independent shared and machine sessions without cross connectedAt: "2026-08-28T00:00:00.000Z", hubReachability: "unknown", }; configureApiTargets(targetsFromMachineStatus("", relayStatus)); + expect(await bootstrapApiSession("shared")).toBe(false); expect(installApiSessionFromHtml("shared", sessionDocumentHtml( "ocx_session_hub", "hub-csrf", "http://localhost", "https://hub.example.test", ))).toBe(true); @@ -592,6 +600,8 @@ test("relay requests carry independent shared and machine sessions without cross const unknown = seen.get("/api/config")!; expect(unknown.get("x-opencodex-api-key")).toBeNull(); expect(unknown.get("x-opencodex-machine-session")).toBeNull(); + for (const headers of seen.values()) expect(headers.has("X-OpenCodex-Device-Proof")).toBe(false); + expect(proofReads).toBe(0); }); test("a mismatched bootstrap response/server origin clears every in-memory session field", async () => { diff --git a/gui/tests/persistent-dashboard.test.ts b/gui/tests/persistent-dashboard.test.ts index e7a226be358..751297e59a5 100644 --- a/gui/tests/persistent-dashboard.test.ts +++ b/gui/tests/persistent-dashboard.test.ts @@ -1,8 +1,10 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { promptForAdminToken } from "../src/admin-token-dialog"; -import { hasApiSession, installApiAuthFetch, resetApiAuthFetchForTests, setRebootstrapTimeoutForTests } from "../src/api"; +import { bootstrapApiSession, configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml, logoutApiSession, resetApiAuthFetchForTests, setRebootstrapTimeoutForTests } from "../src/api"; +const PROOF_KEY = "opencodex.dashboard-device-proof"; +const PROOF = `ocx_proof_${"A".repeat(43)}`; const names = ["window", "document", "navigator", "localStorage", "sessionStorage", "HTMLElement"] as const; let previous: unknown[]; let browser: Window; @@ -15,7 +17,7 @@ beforeEach(() => { }); afterEach(() => { resetApiAuthFetchForTests(); browser.close(); names.forEach((name, i) => Object.defineProperty(globalThis, name, { configurable: true, value: previous[i] })); }); -test("remember is opt-in and asks the server to persist, never browser storage", async () => { +test("remember is opt-in and submits the device choice without storing the admin token", async () => { let requested: unknown; const pending = promptForAdminToken(async (_token, remember) => { requested = remember; return "accepted"; }); const box = document.querySelector('input[name="remember-device"]'); @@ -28,32 +30,52 @@ test("remember is opt-in and asks the server to persist, never browser storage", expect(localStorage.length).toBe(0); expect(sessionStorage.length).toBe(0); expect(document.cookie).toBe(""); }); -test("remember installs the returned GUI session instead of retrying with the admin token", async () => { +test("remember response stores proof and reload sends it only on session bootstrap", async () => { const seen: Array<{ path: string; headers: Headers; init?: RequestInit }> = []; resetApiAuthFetchForTests(async (verify, allowed) => { expect(allowed).toBe(true); expect(await verify("ocx_admin_fixture", true)).toBe("accepted"); return "ocx_admin_fixture"; }); - window.fetch = (async (input, init) => { + let reload = false; + const handler = (async (input, init) => { const path = String(input); const headers = new Headers(init?.headers); seen.push({ path, headers, init }); - if (path.endsWith("/api/session/remember")) return Response.json({ session: { token: "ocx_session_remembered", csrfToken: "csrf-fixture", browserOrigin: window.location.origin, serverOrigin: window.location.origin } }); + if (path.endsWith("/api/session/remember")) return Response.json({ device: { proof: PROOF }, session: { token: "ocx_session_remembered", csrfToken: "csrf-fixture", browserOrigin: window.location.origin, serverOrigin: window.location.origin } }); + if (reload && path === "/opencodex-session" && headers.get("X-OpenCodex-Device-Proof") === PROOF) return new Response(sessionHtml()); if (path === "/api/config" && headers.get("X-OpenCodex-API-Key") === "ocx_session_remembered") return Response.json({ ok: true }); return new Response(null, { status: 401 }); }) as typeof fetch; + window.fetch = handler; installApiAuthFetch(); expect((await window.fetch("/api/config")).status).toBe(200); expect(hasApiSession("shared")).toBe(true); const exchange = seen.find(row => row.path.endsWith("/api/session/remember"))!; expect(exchange.init?.method).toBe("POST"); expect(exchange.init?.credentials).toBe("same-origin"); expect(seen.filter(row => row.path === "/api/config").some(row => row.headers.get("X-OpenCodex-API-Key") === "ocx_admin_fixture")).toBe(false); + expect(localStorage.getItem(PROOF_KEY)).toBe(PROOF); + expect(localStorage.length).toBe(1); expect(sessionStorage.length).toBe(0); expect(document.cookie).toBe(""); + reload = true; + resetApiAuthFetchForTests(async () => { throw new Error("reload should resume without a prompt"); }); + window.fetch = handler; installApiAuthFetch(); + expect(await bootstrapApiSession("shared")).toBe(true); + expect((await window.fetch("/api/config")).status).toBe(200); + await window.fetch("/api/config", { method: "POST" }); + await window.fetch("/v1/models"); + await window.fetch("https://other.example.test/api/config"); + const proofRequests = seen.filter(row => row.headers.has("X-OpenCodex-Device-Proof")); + expect(proofRequests).toHaveLength(1); + expect(proofRequests[0]!.path).toBe("/opencodex-session"); + expect(proofRequests[0]!.init?.redirect).toBe("error"); + for (const row of seen) expect(row.path).not.toContain(PROOF); }); test("obsolete remembered bearer tokens are removed without reading or migrating them", () => { localStorage.setItem("opencodex.remembered-admin-token", "unsafe-legacy"); localStorage.setItem("opencodex.remembered-admin-token:https://server|same-origin", "unsafe-scoped"); localStorage.setItem("opencodex.remembered-admin-token-decoy", "unrelated"); + localStorage.setItem(PROOF_KEY, PROOF); const getter = localStorage.getItem.bind(localStorage); let reads = 0; - localStorage.getItem = key => { reads++; return getter(key); }; + Object.defineProperty(localStorage, "getItem", { configurable: true, value: (key: string) => { reads++; return getter(key); } }); installApiAuthFetch(); expect(reads).toBe(0); expect(getter("opencodex.remembered-admin-token")).toBeNull(); expect(getter("opencodex.remembered-admin-token:https://server|same-origin")).toBeNull(); expect(getter("opencodex.remembered-admin-token-decoy")).toBe("unrelated"); + expect(getter(PROOF_KEY)).toBe(PROOF); }); test("a hung Remember response body is aborted and cannot install a session", async () => { @@ -71,3 +93,91 @@ test("a hung Remember response body is aborted and cannot install a session", as expect((await window.fetch("/api/config")).status).toBe(401); expect(attempts).toBe(1); expect(verdict).toBe("unavailable"); expect(signal?.aborted).toBe(true); expect(hasApiSession("shared")).toBe(false); }); + +function sessionHtml(): string { + return ` + + + `; +} + +for (const storageFailure of ["unavailable", "write throws"] as const) { + test(`Remember reports unavailable and clears its session when storage ${storageFailure}`, async () => { + if (storageFailure === "unavailable") Object.defineProperty(window, "localStorage", { configurable: true, get() { throw new Error("Storage disabled"); } }); + else Object.defineProperty(window, "localStorage", { configurable: true, value: { + get length() { return 0; }, key: () => null, removeItem: () => {}, + getItem: () => null, setItem: () => { throw new Error("Quota exceeded"); }, + } }); + let verdict: unknown; + resetApiAuthFetchForTests(async verify => { verdict = await verify("ocx_admin_fixture", true); return null; }); + window.fetch = (async input => String(input).endsWith("/api/session/remember") + ? Response.json({ device: { proof: PROOF }, session: { token: "ocx_session_remembered", csrfToken: "csrf-fixture", browserOrigin: window.location.origin, serverOrigin: window.location.origin } }) + : new Response(null, { status: 401 })) as typeof fetch; + installApiAuthFetch(); + expect((await window.fetch("/api/config")).status).toBe(401); + expect(verdict).toBe("unavailable"); expect(hasApiSession("shared")).toBe(false); + }); +} + +for (const proof of [undefined, null, 42, `ocx_proof_${"A".repeat(42)}`, `ocx_proof_${"A".repeat(44)}`, `ocx_proof_${"!".repeat(43)}`]) { + test(`Remember rejects malformed device proof: ${String(proof)}`, async () => { + let verdict: unknown; + resetApiAuthFetchForTests(async verify => { verdict = await verify("ocx_admin_fixture", true); return null; }); + window.fetch = (async input => String(input).endsWith("/api/session/remember") + ? Response.json({ device: { proof }, session: { token: "ocx_session_remembered", csrfToken: "csrf-fixture", browserOrigin: window.location.origin, serverOrigin: window.location.origin } }) + : new Response(null, { status: 401 })) as typeof fetch; + installApiAuthFetch(); + expect((await window.fetch("/api/config")).status).toBe(401); + expect(verdict).toBe("unavailable"); expect(hasApiSession("shared")).toBe(false); + expect(localStorage.getItem(PROOF_KEY)).toBeNull(); + }); +} + +for (const failure of ["response", "network"] as const) { + test(`logout keeps proof after ${failure} failure, then retry revokes and removes proof`, async () => { + localStorage.setItem(PROOF_KEY, PROOF); + let attempts = 0; + const seen: Headers[] = []; + window.fetch = (async (input, init) => { + expect(String(input)).toBe("/api/session/logout"); + seen.push(new Headers(init?.headers)); attempts++; + if (attempts === 1 && failure === "network") throw new Error("Offline"); + return new Response(null, { status: attempts === 1 ? 503 : 200 }); + }) as typeof fetch; + installApiAuthFetch(); + expect(installApiSessionFromHtml("shared", sessionHtml())).toBe(true); + expect(await logoutApiSession("shared")).toBe(false); + expect(localStorage.getItem(PROOF_KEY)).toBe(PROOF); expect(hasApiSession("shared")).toBe(true); + expect(await logoutApiSession("shared")).toBe(true); + expect(localStorage.getItem(PROOF_KEY)).toBeNull(); expect(hasApiSession("shared")).toBe(false); + expect(await bootstrapApiSession("shared")).toBe(false); + expect(attempts).toBe(2); + for (const headers of seen) { + expect(headers.get("X-OpenCodex-API-Key")).toBe("ocx_session_remembered"); + expect(headers.get("X-OpenCodex-CSRF-Token")).toBe("csrf-fixture"); + expect(headers.has("X-OpenCodex-Device-Proof")).toBe(false); + } + }); +} + +test("invalid stored proof is omitted from bootstrap", async () => { + localStorage.setItem(PROOF_KEY, "invalid-proof"); + window.fetch = (async (_input, init) => { + expect(new Headers(init?.headers).has("X-OpenCodex-Device-Proof")).toBe(false); + return new Response(null, { status: 401 }); + }) as typeof fetch; + installApiAuthFetch(); + expect(await bootstrapApiSession("shared")).toBe(false); +}); + +test("a bootstrap URL outside the dashboard origin receives no proof", async () => { + localStorage.setItem(PROOF_KEY, PROOF); + window.fetch = (async (_input, init) => { + expect(new Headers(init?.headers).has("X-OpenCodex-Device-Proof")).toBe(false); + return new Response(null, { status: 401 }); + }) as typeof fetch; + installApiAuthFetch(); + const target = { baseUrl: "", serverOrigin: window.location.origin, bootstrapPath: "https://other.example.test/opencodex-session", transport: "same-origin" as const }; + configureApiTargets({ connected: false, machine: { ...target, id: "machine" }, shared: { ...target, id: "shared" } }); + expect(await bootstrapApiSession("shared")).toBe(false); +}); diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 72a39e02960..8fefce13f78 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -7,7 +7,7 @@ in [GUI and management API](gui-and-management-api.md). Model-price editing and ## UX boundary The dashboard is a local control surface, not a separate service. It should reflect the same config -and catalog invariants documented in this folder rather than inventing parallel state. The sign-in form offers opt-in **Remember this device** for direct HTTPS dashboards or loopback, requesting a server-issued HttpOnly session rather than storing an admin token. Both mobile and sidebar actions offer **Forget this device**, using logout to revoke durable and transient device authority; session-change events update both surfaces. Expiry and cookie authentication belong to [GUI and management API](gui-and-management-api.md). +and catalog invariants documented in this folder rather than inventing parallel state. The sign-in form offers opt-in **Remember this device** for direct HTTPS dashboards or loopback, storing an HttpOnly device cookie plus a separate proof in this site's origin-scoped localStorage; neither is the admin token or works alone, cookies span ports of one hostname so the proof binds the dashboard origin (still prefer a dedicated hostname), and storage failure reports Remember unavailable. Both mobile and sidebar **Forget this device** actions use logout to revoke the record and derived sessions immediately after successful publication and remove the proof, retaining it on failure for retry (directory-entry crash durability is best effort); remembered sessions cannot approve GitHub stars, reset-credit consumption, prompt-layer edits, Meta Muse sign-in, desktop compatibility changes or quota validation, which require a fresh local or paired session, and session-change events update both surfaces under [GUI and management API](gui-and-management-api.md). Codex quota cards consume the display cache from `src/codex/quota.ts`. Partial refreshes remove omitted short tuples after their reset deadline; validated main-window absence also removes diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 87f852c8cb4..a09168e682a 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -119,7 +119,7 @@ OpenCodex uses three mutually exclusive reusable admission credential classes: The service token file remains a delivery mechanism for the data-plane environment token; it is not a fourth credential class. A management credential that equals any configured data-plane credential -does not enable management access. The data plane may continue to start, but `/api/*` remains closed. The opt-in bodyless `POST /api/session/remember` exchanges admin-token authority for a host-only HttpOnly, SameSite=Strict device cookie (Secure on HTTPS), never browser-stored admin credentials. `src/server/persistent-gui-session.ts` owns the private hash-only `dashboard-sessions.json` store (128 devices/128 KiB) and fixed 30-day expiry. Enrollment requires direct same-origin HTTPS or loopback; relay, cross-origin and remote HTTP are refused. Cookie hashes bind server origin (including port), browser identity and admin-credential fingerprint. Bootstrap issues short-lived origin/CSRF-bound `remembered-admin` sessions, which never count as pairing or data-key-reveal consent. Every derived session still requires the cookie and durable record; logout durably revokes both before reporting success. Legacy bearer-token storage is purged without reading or migrating it. Tests: `tests/server/persistent-dashboard-session.test.ts`. +does not enable management access. The data plane may continue to start, but `/api/*` remains closed. The opt-in bodyless `POST /api/session/remember` returns a short-lived GUI session plus a device proof for this site's origin-scoped localStorage alongside a host-only HttpOnly, SameSite=Strict device cookie (Secure on HTTPS); neither is the admin token or works alone, and because cookies span ports on one hostname, the proof binds resume to the dashboard origin (a dedicated hostname is still preferred). `src/server/persistent-gui-session.ts` owns the private hash-only `dashboard-sessions.json` store (128 devices/128 KiB), server-origin/browser/admin-credential binding and fixed 30-day expiry; enrollment requires direct same-origin HTTPS or loopback, browser-storage failure is reported unavailable, bootstrap alone sends the proof header, `remembered-admin` sessions never authorize pairing, data-key reveal or consent actions (GitHub star, reset-credit consumption, prompt-layer edits, Meta Muse sign-in, desktop compatibility changes and quota validation require a fresh local or paired session), and successful Forget/logout immediately revokes the record and derived sessions after content fsync and publication, removes the browser proof (failed logout retains it for retry), with directory-entry crash durability best effort and legacy bearer keys purged without reading or migration; tests: `tests/server/persistent-dashboard-session.test.ts`. CLI health collection follows the same boundary without transporting the reusable management credential. Its local-read HMAC capability is an additional single-use, route-scoped admission mechanism, not a reusable credential class. `ocx doctor` and OAuth health derive these capabilities From 5ae0a992988153fb94d51d9fff444001f943593b Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 18 Oct 2026 04:54:29 +0900 Subject: [PATCH 08/10] fix(dashboard): keep shared-session recovery alive during target discovery A session-change event now only reveals readiness; dropping it on the transient clear during connected-target discovery cancelled the requests that recover the shared session (remote-link-route pending-bootstrap cases). Tighten the browser-identity regression so only the User-Agent differs. --- gui/src/App.tsx | 15 +++++++++++---- tests/server/persistent-dashboard-session.test.ts | 6 ++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 7876b14bb27..96b854fe78b 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -159,13 +159,20 @@ export default function App() { useEffect(() => { const unavailable = (event: Event) => { - if ((event as CustomEvent<{ plane?: string }>).detail?.plane === "shared") { - setSharedSessionReady(hasApiSession("shared")); + if ((event as CustomEvent<{ plane?: string }>).detail?.plane === "shared" && !hasApiSession("shared")) { + setSharedSessionReady(false); + } + }; + // A change only ever reveals a session (Remember/Forget controls). Target discovery clears the + // session transiently, and dropping readiness there would cancel the requests that recover it. + const changed = (event: Event) => { + if ((event as CustomEvent<{ plane?: string }>).detail?.plane === "shared" && hasApiSession("shared")) { + setSharedSessionReady(true); } }; window.addEventListener(SESSION_UNAVAILABLE_EVENT, unavailable); - window.addEventListener(SESSION_CHANGED_EVENT, unavailable); - return () => { window.removeEventListener(SESSION_UNAVAILABLE_EVENT, unavailable); window.removeEventListener(SESSION_CHANGED_EVENT, unavailable); }; + window.addEventListener(SESSION_CHANGED_EVENT, changed); + return () => { window.removeEventListener(SESSION_UNAVAILABLE_EVENT, unavailable); window.removeEventListener(SESSION_CHANGED_EVENT, changed); }; }, []); useEffect(() => { diff --git a/tests/server/persistent-dashboard-session.test.ts b/tests/server/persistent-dashboard-session.test.ts index 9d2f01f6107..ba49f6cde32 100644 --- a/tests/server/persistent-dashboard-session.test.ts +++ b/tests/server/persistent-dashboard-session.test.ts @@ -51,8 +51,10 @@ test("a device resumes after a server restart, but not on another origin or brow const restarted = initializeManagementAuthState(config); const resumed = issueGuiSession(request("/opencodex-session", cookie, { "X-OpenCodex-Device-Proof": proof }, "GET"), config, restarted); expect(resumed?.token).toStartWith("ocx_session_"); - expect(issueGuiSession(request("/", cookie, { "User-Agent": "another-device" }, "GET"), config, restarted)).toBeNull(); - const other = new Request("https://other.example.test/", { headers: { Host: "other.example.test", Cookie: cookie, "User-Agent": "Safari-PWA-fixture" } }); + // Valid cookie and proof: only the browser identity differs, so the User-Agent binding is what refuses. + expect(issueGuiSession(request("/opencodex-session", cookie, { "X-OpenCodex-Device-Proof": proof, "User-Agent": "another-device" }, "GET"), config, restarted)).toBeNull(); + expect(issueGuiSession(request("/opencodex-session", cookie, { "X-OpenCodex-Device-Proof": proof }, "GET"), config, restarted)?.issuance).toBe("remembered-admin"); + const other = new Request("https://other.example.test/opencodex-session", { headers: { Host: "other.example.test", Cookie: cookie, "User-Agent": "Safari-PWA-fixture", "X-OpenCodex-Device-Proof": proof } }); expect(issueGuiSession(other, config, restarted)).toBeNull(); }); From 7770892d9ce9f536b9b8e30af2855ec6fe643028 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 18 Oct 2026 05:20:24 +0900 Subject: [PATCH 09/10] test: follow the shared consent predicate and the remember route in source oracles The star-endpoint oracle now checks that sidebar routes delegate to hasHumanDashboardSession and that the predicate itself reads the credential principal and excludes remembered-admin sessions. The headless-parity map lists /api/session/remember as GUI-only, next to /api/session/logout. --- tests/cli/cli-headless-parity.test.ts | 1 + tests/server/startup-prompt.test.ts | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 47a5721e8c8..05359fd66d0 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -579,6 +579,7 @@ describe("headless GUI parity CLI", () => { ["/api/keys/rotate/commit", "ocx access key rotate commit"], ["/api/machine", "ocx connect/status/sync/disconnect"], ["/api/session/logout", "(none — GUI current-session logout)"], + ["/api/session/remember", "(none — GUI remembered-device exchange; the CLI holds no browser cookie jar)"], ["/api/logs", "ocx observe"], ["/api/lab", "ocx lab"], ["/api/config", "ocx config"], diff --git a/tests/server/startup-prompt.test.ts b/tests/server/startup-prompt.test.ts index f576bf54d55..b1317d4341a 100644 --- a/tests/server/startup-prompt.test.ts +++ b/tests/server/startup-prompt.test.ts @@ -127,7 +127,13 @@ describe("startup star prompt", () => { // And that evidence must be the authenticating CREDENTIAL, never a request // header: the admin token is readable by anything running as the user, so a // header-shaped check is forgeable by the exact caller this guard refuses. - expect(routes).toMatch(/principal === "gui-session"/); + // A remembered device session is minted from that same admin token, so the shared + // consent predicate also excludes it. + expect(routes).toMatch(/return hasHumanDashboardSession\(ctx\)/); + const consent = await readText("src/server/management/consent-session.ts"); + expect(consent).toMatch(/ctx\.principal === "gui-session"/); + expect(consent).toMatch(/ctx\.guiSessionIssuance !== "remembered-admin"/); + expect(consent).not.toMatch(/headers\.get\(/); expect(routes).not.toMatch(/hasBrowserSessionEvidence[\s\S]*?headers\.get\("x-opencodex-csrf-token"\)/); }); From ab4d770505a0426c5599fb22dc419d82200801ca Mon Sep 17 00:00:00 2001 From: x3M3x Date: Sun, 18 Oct 2026 20:00:24 +0400 Subject: [PATCH 10/10] fix(dashboard): proof-based Forget visibility and isolated session tests --- gui/src/App.tsx | 12 ++++-- gui/src/api.ts | 11 ++++++ gui/tests/persistent-dashboard.test.ts | 24 +++++++++++- src/server/persistent-gui-session.ts | 5 ++- .../persistent-dashboard-session.test.ts | 39 ++++++++++++++----- 5 files changed, 75 insertions(+), 16 deletions(-) diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 96b854fe78b..dbbff685e33 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -22,7 +22,7 @@ import { NAV_GROUPS, groupForPage, visibleGroupPages } from "./nav-groups"; import { SectionSwitcher } from "./components/SectionSwitcher"; import { useI18n, useT, LOCALES, localeDisplayName, type Locale, type TKey } from "./i18n/shared"; import { Notice, Select, ToastNotice, type NoticeTone } from "./ui"; -import { bootstrapApiSession, canRememberDashboardDevice, configureApiTargets, hasApiSession, installApiAuthFetch, logoutApiSession, SESSION_CHANGED_EVENT, SESSION_UNAVAILABLE_EVENT } from "./api"; +import { bootstrapApiSession, configureApiTargets, DEVICE_PROOF_CHANGED_EVENT, hasApiSession, hasRememberedDashboardDevice, installApiAuthFetch, logoutApiSession, SESSION_CHANGED_EVENT, SESSION_UNAVAILABLE_EVENT } from "./api"; import { apiBaseForPlane, discoverApiTargets, isConnectedRuntime, runtimeRoleFromDocument, standaloneApiTargets, type ApiTargets } from "./api-targets"; import { ConnectPairingForm } from "./connect-pairing"; import { validateRemoteLinkSession } from "./connect-pairing-transport"; @@ -172,7 +172,11 @@ export default function App() { }; window.addEventListener(SESSION_UNAVAILABLE_EVENT, unavailable); window.addEventListener(SESSION_CHANGED_EVENT, changed); - return () => { window.removeEventListener(SESSION_UNAVAILABLE_EVENT, unavailable); window.removeEventListener(SESSION_CHANGED_EVENT, changed); }; + const proofChanged = (event: Event) => { + if ((event as CustomEvent<{ plane?: string }>).detail?.plane === "shared") setSharedSessionEpoch(epoch => epoch + 1); + }; + window.addEventListener(DEVICE_PROOF_CHANGED_EVENT, proofChanged); + return () => { window.removeEventListener(SESSION_UNAVAILABLE_EVENT, unavailable); window.removeEventListener(SESSION_CHANGED_EVENT, changed); window.removeEventListener(DEVICE_PROOF_CHANGED_EVENT, proofChanged); }; }, []); useEffect(() => { @@ -367,9 +371,9 @@ export default function App() { const loggedOut = await logoutApiSession("shared"); setSessionLoggingOut(false); if (loggedOut) setSharedSessionReady(false); - else report(t(canRememberDashboardDevice("shared") ? "connection.forgetDeviceFailed" : "connection.sessionLogoutFailed"), "err"); + else report(t(hasRememberedDashboardDevice("shared") ? "connection.forgetDeviceFailed" : "connection.sessionLogoutFailed"), "err"); }; - const deviceForgetAvailable = canRememberDashboardDevice("shared"); + const deviceForgetAvailable = hasRememberedDashboardDevice("shared"); const endSessionLabel = t(deviceForgetAvailable ? "connection.forgetDevice" : "connection.sessionLogout"); /* diff --git a/gui/src/api.ts b/gui/src/api.ts index 2e0be7a358e..43552da49ea 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -9,6 +9,8 @@ import { adminTokenPromptAllowed, standaloneApiTargets, type ApiPlane, type ApiT export const SESSION_UNAVAILABLE_EVENT = "opencodex:session-unavailable"; /** Token identity changed; contains only the plane, never credential material. */ export const SESSION_CHANGED_EVENT = "opencodex:session-changed"; +/** The remembered-device proof was stored or cleared; contains only the plane. */ +export const DEVICE_PROOF_CHANGED_EVENT = "opencodex:device-proof-changed"; const LEGACY_TOKEN_KEY = "opencodex-api-token"; const DEVICE_PROOF_KEY = "opencodex.dashboard-device-proof"; @@ -196,6 +198,7 @@ export async function logoutApiSession(plane: ApiPlane): Promise { if (runtimes.get(plane) === state && state.logoutBarrier === barrier) { if (canRememberDashboardDevice(plane)) { try { window.localStorage.removeItem(DEVICE_PROOF_KEY); } catch { /* storage may be disabled */ } + reportSessionEvent(plane, DEVICE_PROOF_CHANGED_EVENT); } updateSession(state, blankSession()); state.promptCancelled = false; @@ -311,6 +314,13 @@ export function canRememberDashboardDevice(plane: ApiPlane): boolean { && (window.location.protocol === "https:" || ["localhost", "127.0.0.1", "[::1]"].includes(window.location.hostname)); } +/** Enrollment, not capability: the Forget control exists only once a proof is stored. */ +export function hasRememberedDashboardDevice(plane: ApiPlane): boolean { + if (!canRememberDashboardDevice(plane)) return false; + try { return DEVICE_PROOF_PATTERN.test(window.localStorage.getItem(DEVICE_PROOF_KEY) ?? ""); } + catch { return false; } +} + function targetAbsoluteBase(target: ApiTarget): URL { return new URL(target.baseUrl || "/", window.location.href); } @@ -428,6 +438,7 @@ async function verifyAdminToken(recovery: RecoveryAttempt, token: string, rememb const proof = body?.device?.proof; if (typeof proof !== "string" || !DEVICE_PROOF_PATTERN.test(proof)) throw new Error("Invalid device proof"); window.localStorage.setItem(DEVICE_PROOF_KEY, proof); + reportSessionEvent(plane, DEVICE_PROOF_CHANGED_EVENT); } catch { invalidateSession(state, recovery); return "unavailable"; diff --git a/gui/tests/persistent-dashboard.test.ts b/gui/tests/persistent-dashboard.test.ts index 751297e59a5..7c452920d96 100644 --- a/gui/tests/persistent-dashboard.test.ts +++ b/gui/tests/persistent-dashboard.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { promptForAdminToken } from "../src/admin-token-dialog"; -import { bootstrapApiSession, configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml, logoutApiSession, resetApiAuthFetchForTests, setRebootstrapTimeoutForTests } from "../src/api"; +import { bootstrapApiSession, configureApiTargets, DEVICE_PROOF_CHANGED_EVENT, hasApiSession, hasRememberedDashboardDevice, installApiAuthFetch, installApiSessionFromHtml, logoutApiSession, resetApiAuthFetchForTests, setRebootstrapTimeoutForTests } from "../src/api"; const PROOF_KEY = "opencodex.dashboard-device-proof"; const PROOF = `ocx_proof_${"A".repeat(43)}`; @@ -78,6 +78,28 @@ test("obsolete remembered bearer tokens are removed without reading or migrating expect(getter(PROOF_KEY)).toBe(PROOF); }); +test("Forget visibility follows stored proof enrollment and refreshes on the proof event", async () => { + const events: string[] = []; + window.addEventListener(DEVICE_PROOF_CHANGED_EVENT, event => events.push((event as CustomEvent<{ plane?: string }>).detail?.plane ?? "")); + installApiAuthFetch(); + expect(hasRememberedDashboardDevice("shared")).toBe(false); + resetApiAuthFetchForTests(async (verify, allowed) => allowed && await verify("ocx_admin_fixture", true) === "accepted" ? "ocx_admin_fixture" : null); + window.fetch = (async (input, init) => { + const path = String(input); + if (path.endsWith("/api/session/remember")) return Response.json({ device: { proof: PROOF }, session: { token: "ocx_session_enrolled", csrfToken: "csrf", browserOrigin: window.location.origin, serverOrigin: window.location.origin } }); + if (path.endsWith("/api/session/logout")) return Response.json({ ok: true }); + if (path === "/api/config" && new Headers(init?.headers).get("X-OpenCodex-API-Key") === "ocx_session_enrolled") return Response.json({ ok: true }); + return new Response(null, { status: 401 }); + }) as typeof fetch; + installApiAuthFetch(); + expect((await window.fetch("/api/config")).status).toBe(200); + expect(hasRememberedDashboardDevice("shared")).toBe(true); + expect(events).toEqual(["shared"]); + expect(await logoutApiSession("shared")).toBe(true); + expect(hasRememberedDashboardDevice("shared")).toBe(false); + expect(events).toEqual(["shared", "shared"]); +}); + test("a hung Remember response body is aborted and cannot install a session", async () => { let verdict: unknown; let attempts = 0; let signal: AbortSignal | undefined; resetApiAuthFetchForTests(async verify => { verdict = await verify("ocx_admin_fixture", true); return null; }); diff --git a/src/server/persistent-gui-session.ts b/src/server/persistent-gui-session.ts index da0e73e8879..1a295518cf6 100644 --- a/src/server/persistent-gui-session.ts +++ b/src/server/persistent-gui-session.ts @@ -140,7 +140,10 @@ export function forgetGuiDevice(req: Request, config: OcxConfig, state: State): const digest = deviceDigest(req, origin); if (digest) withConfigMutationLockSync(() => { const rows = readRecords(); - const found = rows.find(row => row.digest === digest && matches(row, req, origin, state, Date.now())); + // User-Agent and expiry protect resumption, not deletion: a browser update between + // Remember and Forget must still remove the record Forget was reached through. + const found = rows.find(row => row.digest === digest && row.serverOrigin === origin + && row.credential === hash(state.token)); if (found) atomicWriteFileNoFollowStreamed(storePath(), fd => writeFileSync(fd, JSON.stringify(rows.filter(row => row !== found)))); for (const [token, session] of state.sessions) if (session.persistentDigest === digest) state.sessions.delete(token); }); diff --git a/tests/server/persistent-dashboard-session.test.ts b/tests/server/persistent-dashboard-session.test.ts index ba49f6cde32..971479b3152 100644 --- a/tests/server/persistent-dashboard-session.test.ts +++ b/tests/server/persistent-dashboard-session.test.ts @@ -1,15 +1,14 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import { initializeManagementAuthState, createManagementSessionControl, issueGuiSession, requireManagementAuth } from "../../src/server/management-auth"; import { handleSessionRoutes } from "../../src/server/management/session-routes"; import type { ManagementContext } from "../../src/server/management/context"; import { remoteConfig } from "../helpers/management-auth-fixture"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; -const previousHome = process.env.OPENCODEX_HOME; -const previousToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; -let home: string; +let home: TempHome; +let previousToken: string | undefined; const config = remoteConfig(); const origin = "https://dashboard.example.test"; function request(path = "/api/session/remember", cookie?: string, overrides: HeadersInit = {}, method = "POST") { @@ -23,8 +22,10 @@ function login() { sessionControl: createManagementSessionControl(state) } as ManagementContext); return { state, response }; } -beforeEach(() => { home = mkdtempSync(join(tmpdir(), "ocx-persistent-dashboard-")); process.env.OPENCODEX_HOME = home; process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "ocx_admin_fixture"; }); -afterEach(() => { process.env.OPENCODEX_HOME = previousHome; process.env.OPENCODEX_ADMIN_AUTH_TOKEN = previousToken; rmSync(home, { recursive: true, force: true }); }); +beforeEach(() => { home = createTempHome("ocx-persistent-dashboard-"); previousToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "ocx_admin_fixture"; }); +// Assigning undefined to process.env stores the literal string "undefined" and poisons later +// tests in this process; absence is restored by deleting the key (the fixture restores homes). +afterEach(() => { if (previousToken === undefined) delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; else process.env.OPENCODEX_ADMIN_AUTH_TOKEN = previousToken; home.remove(); }); test("remember exchanges the admin token for a device cookie and a server-issued session", async () => { const { response } = login(); @@ -37,7 +38,7 @@ test("remember exchanges the admin token for a device cookie and a server-issued expect(body.device.proof).toMatch(/^ocx_proof_[A-Za-z0-9_-]{43}$/); expect(response!.headers.get("Cache-Control")).toBe("no-store"); expect(cookie).not.toContain(body.device.proof); - const disk = readFileSync(join(home, "dashboard-sessions.json"), "utf8"); + const disk = readFileSync(join(home.root, "dashboard-sessions.json"), "utf8"); expect(disk).not.toContain(body.device.proof); expect(Object.keys(JSON.parse(disk)[0]).sort()).toEqual(["credential", "digest", "expiresAt", "proof", "serverOrigin", "userAgent"]); expect(disk).not.toContain("ocx_admin_fixture"); @@ -82,6 +83,24 @@ test("logout durably forgets this device and revokes all resumed sessions", asyn expect(requireManagementAuth(replay, state, config)?.status).toBe(401); }); +test("logout still removes the record after a browser User-Agent change", async () => { + const { state, response } = login(); expect(response?.status).toBe(200); + const cookie = response!.headers.get("set-cookie")!.split(";")[0]!; + const session = (await response!.json()).session; + const req = request("/api/session/logout", cookie, { "User-Agent": "Safari-PWA-upgraded", + "X-OpenCodex-API-Key": session.token, "X-OpenCodex-GUI-Origin": origin, "X-OpenCodex-CSRF-Token": session.csrfToken }); + const result = handleSessionRoutes({ req, url: new URL(req.url), config, principal: "gui-session", sessionControl: createManagementSessionControl(state) } as ManagementContext); + expect(result?.status).toBe(200); + expect(JSON.parse(readFileSync(join(home.root, "dashboard-sessions.json"), "utf8"))).toEqual([]); + expect(issueGuiSession(request("/", cookie, {}, "GET"), config, initializeManagementAuthState(config))).toBeNull(); +}); + +test("the surrounding test process keeps its environment after each round", () => { + // The fixture's beforeEach/afterEach pair runs around this test too, so a passing + // round here exercises restoration rather than asserting the helpers in isolation. + expect(existsSync(join(home.root, "dashboard-sessions.json"))).toBe(false); +}); + test("remember rejects cross-origin, plaintext remote and unauthenticated issuance", () => { const state = initializeManagementAuthState(config); const control = createManagementSessionControl(state); for (const req of [request(undefined, undefined, { Origin: "https://attacker.test" }), new Request("http://dashboard.example.test/api/session/remember", { method: "POST", headers: { Host: "dashboard.example.test", Origin: "http://dashboard.example.test" } })]) { @@ -106,8 +125,8 @@ test("the persistent cookie alone grants no management access, and a derived tok }); test("an unreadable or malformed device store fails closed without issuing a cookie or overwriting it", () => { - writeFileSync(join(home, "dashboard-sessions.json"), "not-json", { mode: 0o600 }); + writeFileSync(join(home.root, "dashboard-sessions.json"), "not-json", { mode: 0o600 }); const { response } = login(); expect(response?.status).toBe(503); expect(response!.headers.get("set-cookie")).toBeNull(); - expect(readFileSync(join(home, "dashboard-sessions.json"), "utf8")).toBe("not-json"); + expect(readFileSync(join(home.root, "dashboard-sessions.json"), "utf8")).toBe("not-json"); });