From 79b83d759b66f2d1bd8aba41c3cc4c153bcd6790 Mon Sep 17 00:00:00 2001 From: pt-act <211776491+pt-act@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:58:02 +0100 Subject: [PATCH] fix(local,cli,react): replace token-in-URL bootstrap with a one-time-code exchange --- .changeset/otc-bootstrap.md | 21 ++++ apps/cli/src/main.ts | 33 +++++- apps/local/src/otc-exchange.test.ts | 139 ++++++++++++++++++++++++++ apps/local/src/otc.ts | 79 +++++++++++++++ apps/local/src/serve.ts | 50 ++++++++- e2e/local/auth.test.ts | 15 +-- e2e/local/local-server.ts | 53 +++++++++- packages/app/src/entry-client.tsx | 18 ++-- packages/react/src/api/local-auth.tsx | 80 +++++++++++---- 9 files changed, 451 insertions(+), 37 deletions(-) create mode 100644 .changeset/otc-bootstrap.md create mode 100644 apps/local/src/otc-exchange.test.ts create mode 100644 apps/local/src/otc.ts diff --git a/.changeset/otc-bootstrap.md b/.changeset/otc-bootstrap.md new file mode 100644 index 0000000000..f18e0b36f2 --- /dev/null +++ b/.changeset/otc-bootstrap.md @@ -0,0 +1,21 @@ +--- +"@executor-js/local-app": patch +"@executor-js/cli": patch +"@executor-js/react": patch +--- + +fix: replace the token-in-URL web bootstrap with a one-time-code exchange + +Opening the web UI previously put the daemon bearer token in the URL +(`?_token=`) and the SPA persisted it to localStorage — both are +leak-prone surfaces (browser history, logs, screen recordings, and +localStorage is readable by any script on the origin). + +`executor web` / `executor open` now mint a one-time code (bearer-gated, +single-use, 60-second TTL, 128-bit entropy, bound to the running daemon +instance) and open `/?_otc=`. On first load the SPA exchanges the +code for the bearer, applies it to the in-memory connection, and strips the +query. The server also sets an HttpOnly SameSite=strict cookie as transport +hardening. Nothing is written to localStorage by the bootstrap path; the +legacy `?_token=` query is still accepted for compatibility with older +daemons but is no longer persisted. diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index fc2a9bf391..78edae1e98 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -1149,7 +1149,10 @@ const runForegroundSession = (input: { try { console.log(`Executor is ready.`); - console.log(`Open: ${baseUrl}/?_token=${server.authToken}`); + const otcCode = server.otcStore?.issue() ?? null; + console.log( + `Open: ${otcCode ? `${baseUrl}/?_otc=${otcCode}` : `${baseUrl}/?_token=${server.authToken}`}`, + ); console.log(`Web: ${baseUrl}`); console.log(`MCP: ${baseUrl}/mcp`); console.log(`OpenAPI: ${baseUrl}/api/docs`); @@ -3269,11 +3272,37 @@ const openRunningLocalWebApp = (): Effect.Effect< } const { origin, auth } = manifest.connection; const token = auth?.kind === "bearer" ? auth.token : undefined; - const url = token ? `${origin}/?_token=${token}` : origin; + if (!token) { + console.log(`Opening ${origin}`); + yield* openInBrowser(origin); + return; + } + // Mint a one-time bootstrap code instead of putting the bearer in the + // URL. The browser exchanges it for the bearer on first load (HttpOnly + // cookie + in-memory connection), and the query is stripped. + const otc = yield* mintOtcForDaemon(origin, token); + const url = otc ? `${origin}/?_otc=${otc}` : `${origin}/?_token=${token}`; console.log(`Opening ${url}`); yield* openInBrowser(url); }); +/** Mint a one-time bootstrap code from the running daemon (bearer-gated). + * Falls back to null on any failure — the caller then falls back to the + * legacy `?_token=` URL rather than failing the open. */ +const mintOtcForDaemon = (origin: string, token: string): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const res = await fetch(`${origin}/api/auth/otc`, { + method: "POST", + headers: { authorization: `Bearer ${token}` }, + }); + if (!res.ok) return null; + const body = (await res.json()) as { readonly code?: unknown }; + return typeof body.code === "string" && body.code.length > 0 ? body.code : null; + }, + catch: () => null, + }).pipe(Effect.catch(() => Effect.succeed(null))); + /** * `executor open` — the friendly way back in. Reads the running local server's * manifest and opens the browser straight to its `?_token=` URL, so the user diff --git a/apps/local/src/otc-exchange.test.ts b/apps/local/src/otc-exchange.test.ts new file mode 100644 index 0000000000..bcc16e406a --- /dev/null +++ b/apps/local/src/otc-exchange.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { startServer, type ServerInstance } from "./serve"; +import { OTC_TTL_MS, makeOtcStore } from "./otc"; + +let clientDir: string; +let dataDir: string; +let server: ServerInstance | null = null; + +const TOKEN = "test-bearer-token"; + +const testHandlers = () => ({ + api: { + handler: async () => new Response("ok"), + dispose: async () => {}, + }, + mcp: { + handleRequest: async () => new Response("ok"), + handleApprovalRequest: async () => new Response("ok"), + handlePausedRequest: async () => new Response("ok"), + close: async () => {}, + }, +}); + +const startTestServer = async (): Promise => { + server = await startServer({ + port: 0, + hostname: "127.0.0.1", + clientDir, + authToken: TOKEN, + handlers: testHandlers(), + }); + return `http://127.0.0.1:${server.port}`; +}; + +beforeEach(() => { + clientDir = mkdtempSync(join(tmpdir(), "exec-otc-serve-")); + dataDir = mkdtempSync(join(tmpdir(), "exec-otc-data-")); + process.env.EXECUTOR_DATA_DIR = dataDir; + process.env.EXECUTOR_SCOPE_DIR = dataDir; + writeFileSync( + join(clientDir, "index.html"), + "index-shell", + ); +}); + +afterEach(async () => { + if (server) { + await server.stop(); + server = null; + } + delete process.env.EXECUTOR_DATA_DIR; + delete process.env.EXECUTOR_SCOPE_DIR; + rmSync(clientDir, { recursive: true, force: true }); + rmSync(dataDir, { recursive: true, force: true }); +}); + +describe("OTC exchange endpoint", () => { + it("mints a code via the bearer-gated route and exchanges it once (200 + HttpOnly cookie)", async () => { + const origin = await startTestServer(); + const mint = await fetch(`${origin}/api/auth/otc`, { + method: "POST", + headers: { authorization: `Bearer ${TOKEN}` }, + }); + expect(mint.status).toBe(200); + const { code } = (await mint.json()) as { code: string }; + expect(code.length).toBeGreaterThanOrEqual(16); // ≥128 bits base64url + + const exchange = await fetch(`${origin}/api/auth/exchange`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: `code=${encodeURIComponent(code)}`, + }); + expect(exchange.status).toBe(200); + const body = (await exchange.json()) as { token: string }; + expect(body.token).toBe(TOKEN); + + const setCookie = exchange.headers.get("set-cookie") ?? ""; + expect(setCookie).toContain("executor_session"); + expect(setCookie).toContain("HttpOnly"); + expect(setCookie).toContain("SameSite=Strict"); + }); + + it("rejects a replayed code (single-use — second exchange is 400)", async () => { + const origin = await startTestServer(); + const mint = await fetch(`${origin}/api/auth/otc`, { + method: "POST", + headers: { authorization: `Bearer ${TOKEN}` }, + }); + const { code } = (await mint.json()) as { code: string }; + + const first = await fetch(`${origin}/api/auth/exchange`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: `code=${encodeURIComponent(code)}`, + }); + expect(first.status).toBe(200); + + const replay = await fetch(`${origin}/api/auth/exchange`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: `code=${encodeURIComponent(code)}`, + }); + expect(replay.status).toBe(400); + }); + + it("rejects an unknown code", async () => { + const origin = await startTestServer(); + const res = await fetch(`${origin}/api/auth/exchange`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "code=never-issued", + }); + expect(res.status).toBe(400); + }); + + it("rejects the mint route without a bearer", async () => { + const origin = await startTestServer(); + const res = await fetch(`${origin}/api/auth/otc`, { method: "POST" }); + expect(res.status).toBe(401); + }); + + it("rejects an expired code (TTL honored by the store)", () => { + let now = 1_000; + const store = makeOtcStore(() => now); + const code = store.issue(); + expect(store.consume(code)).toBe(code); + + // Re-issue after expiry — the consumed code must stay dead even after + // pruning. + const code2 = store.issue(); + now = now + OTC_TTL_MS + 1; + expect(store.consume(code2)).toBeNull(); + expect(store.consume(code)).toBeNull(); + }); +}); diff --git a/apps/local/src/otc.ts b/apps/local/src/otc.ts new file mode 100644 index 0000000000..b5bb42fdf7 --- /dev/null +++ b/apps/local/src/otc.ts @@ -0,0 +1,79 @@ +// --------------------------------------------------------------------------- +// OtcStore — one-time codes for the web bootstrap exchange. +// +// The local daemon's bearer token is the single credential gating every +// surface. The web bootstrap previously shipped it in the URL (`?_token=`) +// and persisted it to localStorage — both are XSS/leak-adjacent surfaces +// (browser history, logs, screen recordings, localStorage read by any script +// on the origin). The OTC flow replaces the URL-token with a one-time code: +// +// 1. `executor web` / `executor open` mints a code from the running daemon +// (bearer-gated endpoint; the CLI already holds the bearer). +// 2. The browser loads `/?_otc=`, POSTs it to the unauthenticated +// `/api/auth/exchange` endpoint, and receives the bearer in the response +// body PLUS an HttpOnly SameSite=strict cookie (transport hardening — +// the cookie is not the request gate, the bearer is; see serve-shared +// makeIsAuthorized). +// 3. The client applies the bearer to the in-memory connection and strips +// the query. Nothing is written to localStorage. +// +// Codes are single-use, TTL-bounded (≤60s), high-entropy (≥128 bits), bound +// to the daemon instance (the in-memory map dies with the process, so a code +// can never be replayed against a future daemon generation), and never +// logged. +// --------------------------------------------------------------------------- + +import { randomBytes } from "node:crypto"; + +export const OTC_TTL_MS = 60 * 1000; +const OTC_ENTROPY_BYTES = 16; // 128 bits + +interface OtcEntry { + readonly code: string; + readonly expiresAt: number; +} + +export interface OtcStore { + /** Mint a single-use code valid for OTC_TTL_MS. */ + readonly issue: () => string; + /** + * Consume a code. Returns the code's id on success (after which the code is + * dead), or null if the code is unknown, already consumed, or expired. + * Consumption is destructive: a consumed code can never be redeemed again. + */ + readonly consume: (code: string) => string | null; +} + +/** In-memory OTC store. Instance-bound by construction. */ +export const makeOtcStore = (now: () => number = Date.now): OtcStore => { + const codes = new Map(); + + const pruneExpired = (): void => { + const t = now(); + for (const [code, entry] of codes) { + if (entry.expiresAt <= t) codes.delete(code); + } + }; + + return { + issue: () => { + pruneExpired(); + // Collision odds are negligible at 128 bits, but loop anyway so a + // pathological collision can never silently clobber a live code. + let code = randomBytes(OTC_ENTROPY_BYTES).toString("base64url"); + while (codes.has(code)) { + code = randomBytes(OTC_ENTROPY_BYTES).toString("base64url"); + } + codes.set(code, { code, expiresAt: now() + OTC_TTL_MS }); + return code; + }, + + consume: (code) => { + pruneExpired(); + const entry = codes.get(code); + if (entry === undefined) return null; + codes.delete(code); + return entry.expiresAt > now() ? entry.code : null; + }, + }; +}; diff --git a/apps/local/src/serve.ts b/apps/local/src/serve.ts index 71725b09cc..de26dd9c93 100644 --- a/apps/local/src/serve.ts +++ b/apps/local/src/serve.ts @@ -13,6 +13,7 @@ import type { Subprocess } from "bun"; import { setOAuthCompletionListener } from "@executor-js/api"; import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server"; import { loadOrMintLocalAuthToken } from "./auth"; +import { makeOtcStore, type OtcStore } from "./otc"; import { publishOAuthResult, waitForOAuthResult } from "./oauth-result-store"; import { disposeAnalytics } from "./analytics"; import { startIntegrationsRefresh } from "./integrations"; @@ -276,6 +277,8 @@ export interface ServerInstance { /** The effective bearer token this server validates. Callers publish it in the * manifest, print the `?_token=` bootstrap URL, and hand it to MCP clients. */ authToken: string; + /** One-time bootstrap-code store for the web OTC exchange. */ + otcStore: OtcStore; stop: () => Promise; } @@ -338,6 +341,9 @@ export async function startServer(opts: StartServerOptions = {}): Promise([ ...DEFAULT_ALLOWED_HOSTS, @@ -425,6 +431,46 @@ export async function startServer(opts: StartServerOptions = {}): Promise "")) + .split("&") + .find((kv) => kv.startsWith("code=")) + ?.slice("code=".length); + const redeemed = code ? otcStore.consume(code) : null; + if (redeemed === null) { + return withCors(new Response("Invalid or expired code", { status: 400 })); + } + // The bearer is the request gate for /api; the HttpOnly cookie is + // transport hardening (SameSite=strict, never readable by JS). + return withCors( + new Response(JSON.stringify({ token: authToken }), { + status: 200, + headers: { + "content-type": "application/json", + "set-cookie": `executor_session=${authToken}; Path=/; HttpOnly; SameSite=Strict; Max-Age=604800`, + }, + }), + ); + } + // OAuth callbacks and CIMD documents are reached by the external // provider, which cannot carry our local bearer. Everything else under // /api and /mcp requires the bearer. @@ -524,6 +570,7 @@ export async function startServer(opts: StartServerOptions = {}): Promise + yield* withLocalServer(cli, runDir, ({ url }) => browser.session(identity, async ({ page, step }) => { - await step("Open the ?_token URL printed by executor web --foreground", async () => { + await step("Open the bootstrap URL printed by executor web --foreground", async () => { await page.goto(url, { waitUntil: "domcontentloaded" }); await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 }); // Integrations actually LOAD (the built-in Executor integration) — proves @@ -40,10 +40,13 @@ scenario( // testid: the list renders each integration's name + slug, never the // literal "built-in" (that string is only an internal `kind`). await page.getByTestId("integration-entry-executor").first().waitFor({ timeout: 30_000 }); - // The token is moved out of the URL and persisted to localStorage. - expect(new URL(page.url()).searchParams.has("_token")).toBe(false); + // The bootstrap credential is single-use and never persisted: the + // query is stripped and localStorage stays empty — the bearer lives + // only in the in-memory connection. + const query = new URL(page.url()).search; + expect(query.includes("_otc") || query.includes("_token")).toBe(false); const stored = await page.evaluate(() => localStorage.getItem("executor.authToken")); - expect(stored).toBe(token); + expect(stored).toBe(null); }); }), ); diff --git a/e2e/local/local-server.ts b/e2e/local/local-server.ts index 798a62cc9f..0d5bc094ad 100644 --- a/e2e/local/local-server.ts +++ b/e2e/local/local-server.ts @@ -16,8 +16,11 @@ import { markFocus, markRecordingStart } from "../src/timeline"; const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); -/** The `Open: …/?_token=` URL the CLI prints once the server is up. */ -export const TOKEN_URL = /http:\/\/127\.0\.0\.1:\d+\/\?_token=[A-Za-z0-9_-]+/; +/** The `Open: …/?_otc=` or `Open: …/?_token=` URL the CLI + * prints once the server is up. The OTC form is the default bootstrap (the + * bearer never rides the URL); the token form is the daemon's fallback when + * no OTC store is wired. */ +export const TOKEN_URL = /http:\/\/127\.0\.0\.1:\d+\/\?_(?:otc|token)=[A-Za-z0-9_-]+/; export interface ServerHandle { /** The full `?_token=` bootstrap URL (origin + token). */ @@ -98,7 +101,7 @@ export const withLocalServer = ( const url = TOKEN_URL.exec(snapshot.text)?.[0]; if (!url) { throw new Error( - `executor web --foreground printed no ?_token URL:\n${snapshot.text.slice(-600)}`, + `executor web --foreground printed no bootstrap URL:\n${snapshot.text.slice(-600)}`, ); } publishUrl(url); @@ -125,10 +128,50 @@ export const withLocalServer = ( Effect.gen(function* () { const url = yield* Effect.promise(() => urlReady); const parsed = new URL(url); + // Resolve the bearer: a `?_token=` URL carries it directly; a + // `?_otc=` URL carries a single-use bootstrap code, redeemed the + // same way the production client does (POST /api/auth/exchange). + // Codes are single-use, and redeeming the printed one kills it for + // the browser — so after redeeming, mint a FRESH code via the + // bearer-gated /api/auth/otc endpoint and rebuild the bootstrap + // URL. Tests get a live bearer AND a live browser bootstrap URL, + // exactly like a real user session. + const token = yield* Effect.promise(async () => { + const direct = parsed.searchParams.get("_token"); + if (direct !== null) return { token: direct, url }; + const code = parsed.searchParams.get("_otc"); + if (code === null) throw new Error("bootstrap URL carries no credential"); + const res = await fetch(new URL("/api/auth/exchange", parsed.origin), { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: `code=${encodeURIComponent(code)}`, + }); + if (!res.ok) { + throw new Error(`OTC exchange failed: HTTP ${res.status}`); + } + const body = (await res.json()) as { readonly token?: unknown }; + if (typeof body.token !== "string" || body.token.length === 0) { + throw new Error("OTC exchange returned no token"); + } + const mintRes = await fetch(new URL("/api/auth/otc", parsed.origin), { + method: "POST", + headers: { authorization: `Bearer ${body.token}` }, + }); + if (!mintRes.ok) { + throw new Error(`OTC mint failed: HTTP ${mintRes.status}`); + } + const minted = (await mintRes.json()) as { readonly code?: unknown }; + if (typeof minted.code !== "string" || minted.code.length === 0) { + throw new Error("OTC mint returned no code"); + } + const fresh = new URL(parsed.origin + "/"); + fresh.searchParams.set("_otc", minted.code); + return { token: body.token, url: fresh.toString() }; + }); yield* body({ - url, + url: token.url, origin: parsed.origin, - token: parsed.searchParams.get("_token")!, + token: token.token, }).pipe(Effect.ensuring(Effect.sync(() => signalBodyDone()))); }), ], diff --git a/packages/app/src/entry-client.tsx b/packages/app/src/entry-client.tsx index 1c17a8bc9e..703c5e3bc9 100644 --- a/packages/app/src/entry-client.tsx +++ b/packages/app/src/entry-client.tsx @@ -17,11 +17,17 @@ if ("executor" in window && navigator.platform.includes("Mac")) { document.documentElement.classList.add("executor-desktop-macos"); } -// Resolve the local bearer token (?_token → localStorage → dev global) and set -// the connection's auth BEFORE the router mounts, so the first API atom carries -// it. No-op on desktop (the main process injects the header). -bootstrapLocalAuthToken(); +// Resolve the local bearer token (?_otc exchange → ?_token → localStorage) and +// set the connection's auth BEFORE the router mounts, so the first API atom +// carries it. No-op on desktop (the main process injects the header). The OTC +// exchange is a network round trip — await it (bounded by the fetch itself) +// so the bearer lands on the connection before the first atom fires. +const mountApp = async (): Promise => { + await bootstrapLocalAuthToken(); -const router = getRouter(); + const router = getRouter(); -ReactDOM.createRoot(document.getElementById("root")!).render(); + ReactDOM.createRoot(document.getElementById("root")!).render(); +}; + +void mountApp(); diff --git a/packages/react/src/api/local-auth.tsx b/packages/react/src/api/local-auth.tsx index fd7d1ee82d..c41c15e5e4 100644 --- a/packages/react/src/api/local-auth.tsx +++ b/packages/react/src/api/local-auth.tsx @@ -7,10 +7,11 @@ * * - Desktop: the Electron main process injects the header at the session * layer, so the renderer never needs the token and this module no-ops. - * - Standalone web AND dev (vite): the server prints `…/?_token=`. - * `bootstrapLocalAuthToken` reads it once, stores it in localStorage, strips - * it from the URL, and sets the connection's bearer auth. Subsequent loads - * read it from localStorage. Dev uses the exact same path — no dev-only + * - Standalone web AND dev (vite): the server prints `…/?_otc=`. + * `bootstrapLocalAuthToken` POSTs the code to the exchange endpoint, + * receives the bearer in the response body (plus an HttpOnly cookie), + * applies it to the in-memory connection, and strips the query. Nothing + * is written to localStorage. Dev uses the exact same path — no dev-only * token injection. * * When a request still 401s (cleared storage, rotated token), the API client @@ -57,34 +58,79 @@ const applyBearer = (token: string): void => { * mounts. Order: `?_token` URL param (one-time, persisted + stripped) → * localStorage. Identical in dev and prod. */ -export const bootstrapLocalAuthToken = (): void => { +const EXCHANGE_PATH = "/api/auth/exchange"; + +/** + * Exchange a one-time code for the local bearer. The response body carries the + * token (applied to the in-memory connection) and the server also sets an + * HttpOnly SameSite=strict cookie (transport hardening; the bearer header + * remains the /api request gate). Returns true on success. + */ +// oxlint-disable executor/no-try-catch-or-throw -- boundary: browser fetch in a synchronous bootstrap path; any network failure collapses to false and the auth gate renders +const exchangeOtc = async (code: string): Promise => { + try { + const res = await fetch(EXCHANGE_PATH, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: `code=${encodeURIComponent(code)}`, + }); + if (!res.ok) return false; + const body = (await res.json()) as { readonly token?: unknown }; + if (typeof body.token !== "string" || body.token.length === 0) return false; + applyBearer(body.token); + return true; + } catch { + return false; + } +}; +// oxlint-enable executor/no-try-catch-or-throw + +export const bootstrapLocalAuthToken = (): Promise | void => { const url = globalThis.window ? new URL(window.location.href) : null; - const fromUrl = url?.searchParams.get("_token") ?? null; + const fromOtc = url?.searchParams.get("_otc") ?? null; + const fromUrlToken = url?.searchParams.get("_token") ?? null; const stripCacheBust = url?.searchParams.has(DESKTOP_LAUNCH_CACHE_BUST_PARAM) ?? false; if (stripCacheBust) { url!.searchParams.delete(DESKTOP_LAUNCH_CACHE_BUST_PARAM); } + const stripQuery = (): void => { + globalThis.window?.history?.replaceState(null, "", url!.pathname + url!.search + url!.hash); + }; + if (isDesktopBridge()) { - if (fromUrl) { - url!.searchParams.delete("_token"); - } - if (stripCacheBust || fromUrl) { - globalThis.window?.history?.replaceState(null, "", url!.pathname + url!.search + url!.hash); - } + // Desktop injects the bearer at the session layer; nothing to exchange. + if (fromOtc) url!.searchParams.delete("_otc"); + if (fromUrlToken) url!.searchParams.delete("_token"); + if (stripCacheBust || fromOtc || fromUrlToken) stripQuery(); return; } - if (fromUrl) { - persistToken(fromUrl); + if (fromOtc) { + // The code is single-use, so strip the query immediately regardless of + // outcome (a failed exchange falls through to the stored token or the + // auth gate). The returned promise is awaited by the caller + // (entry-client) before the router mounts: the bearer must be on the + // connection before the first API atom fires, or those atoms 401 and + // cache the failure state — the auth gate would render even after a + // successful late exchange. + url!.searchParams.delete("_otc"); + stripQuery(); + return exchangeOtc(fromOtc).then(() => undefined); + } + + if (fromUrlToken) { + // Legacy fallback: dev servers / older daemons may still print ?_token=. + // Accepted for compatibility but never persisted — the OTC path is the + // default and this branch is deprecated. url!.searchParams.delete("_token"); - globalThis.window?.history?.replaceState(null, "", url!.pathname + url!.search + url!.hash); - applyBearer(fromUrl); + stripQuery(); + applyBearer(fromUrlToken); return; } if (stripCacheBust) { - globalThis.window?.history?.replaceState(null, "", url!.pathname + url!.search + url!.hash); + stripQuery(); } const stored = readStoredToken();