feat(web): protect launcher dashboard with token and SPA login (#1953)

Add token-based authentication for the Launcher's embedded Web Dashboard.

- Ephemeral token generated in-memory each run (or via PICOCLAW_LAUNCHER_TOKEN env var)
- HMAC-SHA256 session cookie (HttpOnly, SameSite=Lax, Secure when HTTPS)
- Bearer token support for API/script access
- Rate limiting on login (10 attempts/IP/min)
- Referrer-Policy: no-referrer on all responses
- POST-only logout with JSON content-type (CSRF-safe)
- System tray "Copy dashboard token" action
- Login page shows contextual help (console/tray/log file path)
- Path traversal protection via path.Clean
- X-Forwarded-Host/Port/Proto support for reverse proxy deployments
- Full i18n support (English, Chinese)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zeed zhao
2026-03-29 13:11:43 +08:00
committed by GitHub
parent 27f638e909
commit 6ea364e67d
44 changed files with 1617 additions and 45 deletions
+3 -1
View File
@@ -1,5 +1,7 @@
// API client for channels navigation and channel-specific config flows.
import { launcherFetch } from "@/api/http"
export type ChannelConfig = Record<string, unknown>
export type AppConfig = Record<string, unknown>
@@ -22,7 +24,7 @@ interface ConfigActionResponse {
const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, options)
const res = await launcherFetch(`${BASE_URL}${path}`, options)
if (!res.ok) {
let message = `API error: ${res.status} ${res.statusText}`
try {
+3 -1
View File
@@ -1,3 +1,5 @@
import { launcherFetch } from "@/api/http"
// API client for gateway process management.
interface GatewayStatusResponse {
@@ -27,7 +29,7 @@ interface GatewayActionResponse {
const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, options)
const res = await launcherFetch(`${BASE_URL}${path}`, options)
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`)
}
+42
View File
@@ -0,0 +1,42 @@
import { isLauncherLoginPathname } from "@/lib/launcher-login-path"
function isLauncherLoginPath(): boolean {
if (typeof globalThis.location === "undefined") {
return false
}
if (isLauncherLoginPathname(globalThis.location.pathname || "/")) {
return true
}
try {
return isLauncherLoginPathname(
new URL(globalThis.location.href).pathname || "/",
)
} catch {
return false
}
}
/**
* Same-origin fetch that sends cookies; redirects to launcher login on 401 JSON responses.
* Skips redirect while already on the login page to avoid reload loops (e.g. gateway poll).
*/
export async function launcherFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
const res = await fetch(input, {
credentials: "same-origin",
...init,
})
if (res.status === 401) {
const ct = res.headers.get("content-type") || ""
if (
ct.includes("application/json") &&
typeof globalThis.location !== "undefined" &&
!isLauncherLoginPath()
) {
globalThis.location.assign("/launcher-login")
}
}
return res
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Dashboard launcher token login. Uses plain fetch (not launcherFetch) to avoid
* redirect loops on 401 while on the login page.
*/
export async function postLauncherDashboardLogin(
token: string,
): Promise<boolean> {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ token: token.trim() }),
})
return res.ok
}
export type LauncherAuthTokenHelp = {
env_var_name: string
log_file?: string
tray_copy_menu: boolean
console_stdout: boolean
}
export type LauncherAuthStatus = {
authenticated: boolean
token_help?: LauncherAuthTokenHelp
}
export async function getLauncherAuthStatus(): Promise<LauncherAuthStatus> {
const res = await fetch("/api/auth/status", {
method: "GET",
credentials: "same-origin",
})
if (!res.ok) {
throw new Error(`status ${res.status}`)
}
return (await res.json()) as LauncherAuthStatus
}
export async function postLauncherDashboardLogout(): Promise<boolean> {
const res = await fetch("/api/auth/logout", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: "{}",
})
return res.ok
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { launcherFetch } from "@/api/http"
import { refreshGatewayState } from "@/store/gateway"
// API client for model list management.
@@ -39,7 +40,7 @@ interface ModelActionResponse {
const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, options)
const res = await launcherFetch(`${BASE_URL}${path}`, options)
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`)
}
+3 -1
View File
@@ -1,3 +1,5 @@
import { launcherFetch } from "@/api/http"
export type OAuthProvider = "openai" | "anthropic" | "google-antigravity"
export type OAuthMethod = "browser" | "device_code" | "token"
@@ -51,7 +53,7 @@ interface OAuthProvidersResponse {
const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, options)
const res = await launcherFetch(`${BASE_URL}${path}`, options)
if (!res.ok) {
const message = await res.text()
throw new Error(message || `API error: ${res.status} ${res.statusText}`)
+3 -1
View File
@@ -1,3 +1,5 @@
import { launcherFetch } from "@/api/http"
// API client for Pico Channel configuration.
interface PicoTokenResponse {
@@ -16,7 +18,7 @@ interface PicoSetupResponse {
const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, options)
const res = await launcherFetch(`${BASE_URL}${path}`, options)
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`)
}
+5 -3
View File
@@ -1,5 +1,7 @@
// Sessions API — list and retrieve chat session history
import { launcherFetch } from "@/api/http"
export interface SessionSummary {
id: string
title: string
@@ -26,7 +28,7 @@ export async function getSessions(
limit: limit.toString(),
})
const res = await fetch(`/api/sessions?${params.toString()}`)
const res = await launcherFetch(`/api/sessions?${params.toString()}`)
if (!res.ok) {
throw new Error(`Failed to fetch sessions: ${res.status}`)
}
@@ -34,7 +36,7 @@ export async function getSessions(
}
export async function getSessionHistory(id: string): Promise<SessionDetail> {
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`)
const res = await launcherFetch(`/api/sessions/${encodeURIComponent(id)}`)
if (!res.ok) {
throw new Error(`Failed to fetch session ${id}: ${res.status}`)
}
@@ -42,7 +44,7 @@ export async function getSessionHistory(id: string): Promise<SessionDetail> {
}
export async function deleteSession(id: string): Promise<void> {
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`, {
const res = await launcherFetch(`/api/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
})
if (!res.ok) {
+4 -2
View File
@@ -1,3 +1,5 @@
import { launcherFetch } from "@/api/http"
export interface SkillSupportItem {
name: string
path: string
@@ -22,7 +24,7 @@ interface SkillActionResponse {
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(path, options)
const res = await launcherFetch(path, options)
if (!res.ok) {
throw new Error(await extractErrorMessage(res))
}
@@ -41,7 +43,7 @@ export async function importSkill(file: File): Promise<SkillActionResponse> {
const formData = new FormData()
formData.set("file", file)
const res = await fetch("/api/skills/import", {
const res = await launcherFetch("/api/skills/import", {
method: "POST",
body: formData,
})
+3 -1
View File
@@ -1,3 +1,5 @@
import { launcherFetch } from "@/api/http"
export interface AutoStartStatus {
enabled: boolean
supported: boolean
@@ -12,7 +14,7 @@ export interface LauncherConfig {
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(path, options)
const res = await launcherFetch(path, options)
if (!res.ok) {
let message = `API error: ${res.status} ${res.statusText}`
try {
+3 -1
View File
@@ -1,3 +1,5 @@
import { launcherFetch } from "@/api/http"
export interface ToolSupportItem {
name: string
description: string
@@ -16,7 +18,7 @@ interface ToolActionResponse {
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(path, options)
const res = await launcherFetch(path, options)
if (!res.ok) {
let message = `API error: ${res.status} ${res.statusText}`
try {
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { patchAppConfig } from "@/api/channels"
import { launcherFetch } from "@/api/http"
import {
getAutoStartStatus,
getLauncherConfig,
@@ -50,7 +51,7 @@ export function ConfigPage() {
const { data, isLoading, error } = useQuery({
queryKey: ["config"],
queryFn: async () => {
const res = await fetch("/api/config")
const res = await launcherFetch("/api/config")
if (!res.ok) {
throw new Error("Failed to load config")
}
@@ -5,6 +5,7 @@ import { useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { launcherFetch } from "@/api/http"
import { PageHeader } from "@/components/page-header"
import {
AlertDialog,
@@ -28,7 +29,7 @@ export function RawConfigPage() {
const { data: config, isLoading } = useQuery({
queryKey: ["config"],
queryFn: async () => {
const res = await fetch("/api/config")
const res = await launcherFetch("/api/config")
if (!res.ok) {
throw new Error("Failed to fetch config")
}
@@ -38,7 +39,7 @@ export function RawConfigPage() {
const mutation = useMutation({
mutationFn: async (newConfig: string) => {
const res = await fetch("/api/config", {
const res = await launcherFetch("/api/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: newConfig,
@@ -14,6 +14,18 @@ export function normalizeWsUrlForBrowser(wsUrl: string): string {
if (isLocalHost && !isBrowserLocal) {
parsedUrl.hostname = window.location.hostname
finalWsUrl = parsedUrl.toString()
} else if (
isLocalHost &&
isBrowserLocal &&
parsedUrl.hostname !== window.location.hostname &&
(parsedUrl.hostname === "127.0.0.1" ||
parsedUrl.hostname === "localhost") &&
(window.location.hostname === "127.0.0.1" ||
window.location.hostname === "localhost")
) {
// Same machine, but cookies are host-specific; match the page origin.
parsedUrl.hostname = window.location.hostname
finalWsUrl = parsedUrl.toString()
}
} catch (error) {
console.warn("Could not parse ws_url:", error)
+14
View File
@@ -14,6 +14,20 @@
"config": "Config",
"logs": "Logs"
},
"launcherLogin": {
"title": "Launcher access",
"description": "Sign in with the dashboard access token for this launcher process (it may change after each restart unless you pin it with an environment variable).",
"tokenLabel": "Token",
"tokenPlaceholder": "Enter access token",
"submit": "Continue to Dashboard",
"errorInvalid": "Invalid token. Please try again.",
"errorNetwork": "Network error. Please try again.",
"helpTitle": "Where to find the token",
"helpConsole": "Console mode: printed in the terminal when the launcher starts.",
"helpTray": "Tray mode: menu «Copy dashboard token».",
"helpLogFile": "Log file (startup line includes the token): {{path}}",
"helpEnv": "Stable token: set {{env}}."
},
"chat": {
"welcome": "How can I help you today?",
"welcomeDesc": "Ask me about weather, settings, or any other tasks. I'm here to assist you.",
+14
View File
@@ -14,6 +14,20 @@
"config": "配置",
"logs": "日志"
},
"launcherLogin": {
"title": "Launcher 访问验证",
"description": "请使用当前 Launcher 进程的访问口令登录(每次重启可能变化,除非用环境变量固定)。",
"tokenLabel": "令牌",
"tokenPlaceholder": "输入访问令牌",
"submit": "进入 Dashboard",
"errorInvalid": "令牌错误,请重试。",
"errorNetwork": "网络错误,请重试。",
"helpTitle": "口令在哪里",
"helpConsole": "控制台模式:启动时在终端输出。",
"helpTray": "托盘模式:菜单「复制控制台口令」。",
"helpLogFile": "日志文件(启动时会写入口令):{{path}}",
"helpEnv": "固定口令:设置环境变量 {{env}}。"
},
"chat": {
"welcome": "今天我能为您做些什么?",
"welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。",
@@ -0,0 +1,9 @@
/** Normalize URL pathname for comparisons (trailing slashes, empty). */
export function normalizePathname(p: string): string {
const t = p.replace(/\/+$/, "")
return t === "" ? "/" : t
}
export function isLauncherLoginPathname(pathname: string): boolean {
return normalizePathname(pathname) === "/launcher-login"
}
+21
View File
@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as ModelsRouteImport } from './routes/models'
import { Route as LogsRouteImport } from './routes/logs'
import { Route as LauncherLoginRouteImport } from './routes/launcher-login'
import { Route as CredentialsRouteImport } from './routes/credentials'
import { Route as ConfigRouteImport } from './routes/config'
import { Route as AgentRouteImport } from './routes/agent'
@@ -31,6 +32,11 @@ const LogsRoute = LogsRouteImport.update({
path: '/logs',
getParentRoute: () => rootRouteImport,
} as any)
const LauncherLoginRoute = LauncherLoginRouteImport.update({
id: '/launcher-login',
path: '/launcher-login',
getParentRoute: () => rootRouteImport,
} as any)
const CredentialsRoute = CredentialsRouteImport.update({
id: '/credentials',
path: '/credentials',
@@ -83,6 +89,7 @@ export interface FileRoutesByFullPath {
'/agent': typeof AgentRouteWithChildren
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
'/launcher-login': typeof LauncherLoginRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/skills': typeof AgentSkillsRoute
@@ -96,6 +103,7 @@ export interface FileRoutesByTo {
'/agent': typeof AgentRouteWithChildren
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
'/launcher-login': typeof LauncherLoginRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/skills': typeof AgentSkillsRoute
@@ -110,6 +118,7 @@ export interface FileRoutesById {
'/agent': typeof AgentRouteWithChildren
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
'/launcher-login': typeof LauncherLoginRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/skills': typeof AgentSkillsRoute
@@ -125,6 +134,7 @@ export interface FileRouteTypes {
| '/agent'
| '/config'
| '/credentials'
| '/launcher-login'
| '/logs'
| '/models'
| '/agent/skills'
@@ -138,6 +148,7 @@ export interface FileRouteTypes {
| '/agent'
| '/config'
| '/credentials'
| '/launcher-login'
| '/logs'
| '/models'
| '/agent/skills'
@@ -151,6 +162,7 @@ export interface FileRouteTypes {
| '/agent'
| '/config'
| '/credentials'
| '/launcher-login'
| '/logs'
| '/models'
| '/agent/skills'
@@ -165,6 +177,7 @@ export interface RootRouteChildren {
AgentRoute: typeof AgentRouteWithChildren
ConfigRoute: typeof ConfigRouteWithChildren
CredentialsRoute: typeof CredentialsRoute
LauncherLoginRoute: typeof LauncherLoginRoute
LogsRoute: typeof LogsRoute
ModelsRoute: typeof ModelsRoute
}
@@ -185,6 +198,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LogsRouteImport
parentRoute: typeof rootRouteImport
}
'/launcher-login': {
id: '/launcher-login'
path: '/launcher-login'
fullPath: '/launcher-login'
preLoaderRoute: typeof LauncherLoginRouteImport
parentRoute: typeof rootRouteImport
}
'/credentials': {
id: '/credentials'
path: '/credentials'
@@ -292,6 +312,7 @@ const rootRouteChildren: RootRouteChildren = {
AgentRoute: AgentRouteWithChildren,
ConfigRoute: ConfigRouteWithChildren,
CredentialsRoute: CredentialsRoute,
LauncherLoginRoute: LauncherLoginRoute,
LogsRoute: LogsRoute,
ModelsRoute: ModelsRoute,
}
+40 -3
View File
@@ -1,19 +1,56 @@
import { Outlet, createRootRoute } from "@tanstack/react-router"
import {
Outlet,
createRootRoute,
useRouterState,
} from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
import { useEffect } from "react"
import { AppLayout } from "@/components/app-layout"
import { initializeChatStore } from "@/features/chat/controller"
import { isLauncherLoginPathname } from "@/lib/launcher-login-path"
const RootLayout = () => {
// Prefer the real address bar path: stale embedded bundles may not register
// /launcher-login in the route tree, which would otherwise keep AppLayout +
// gateway polling → 401 → launcherFetch redirect loop.
const routerState = useRouterState({
select: (s) => ({
pathname: s.location.pathname,
matches: s.matches,
}),
})
const windowPath =
typeof globalThis.location !== "undefined"
? globalThis.location.pathname || "/"
: routerState.pathname
const isLauncherLogin =
isLauncherLoginPathname(windowPath) ||
isLauncherLoginPathname(routerState.pathname) ||
routerState.matches.some((m) => m.routeId === "/launcher-login")
useEffect(() => {
if (isLauncherLogin) {
return
}
initializeChatStore()
}, [])
}, [isLauncherLogin])
if (isLauncherLogin) {
return (
<>
<Outlet />
{import.meta.env.DEV ? <TanStackRouterDevtools /> : null}
</>
)
}
return (
<AppLayout>
<Outlet />
<TanStackRouterDevtools />
{import.meta.env.DEV ? <TanStackRouterDevtools /> : null}
</AppLayout>
)
}
+184
View File
@@ -0,0 +1,184 @@
import { IconLanguage, IconMoon, IconSun } from "@tabler/icons-react"
import { createFileRoute } from "@tanstack/react-router"
import * as React from "react"
import { useTranslation } from "react-i18next"
import {
getLauncherAuthStatus,
postLauncherDashboardLogin,
type LauncherAuthTokenHelp,
} from "@/api/launcher-auth"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useTheme } from "@/hooks/use-theme"
function LauncherLoginPage() {
const { t, i18n } = useTranslation()
const { theme, toggleTheme } = useTheme()
const [token, setToken] = React.useState("")
const [submitting, setSubmitting] = React.useState(false)
const [error, setError] = React.useState("")
const [tokenHelp, setTokenHelp] = React.useState<LauncherAuthTokenHelp | null>(
null,
)
React.useEffect(() => {
let cancelled = false
void getLauncherAuthStatus()
.then((s) => {
if (cancelled || s.authenticated || !s.token_help) {
return
}
setTokenHelp(s.token_help)
})
.catch(() => {
/* ignore; login form still usable */
})
return () => {
cancelled = true
}
}, [])
const loginWithToken = React.useCallback(
async (tokenValue: string) => {
setError("")
setSubmitting(true)
try {
const ok = await postLauncherDashboardLogin(tokenValue)
if (ok) {
globalThis.location.assign("/")
return
}
setError(t("launcherLogin.errorInvalid"))
} catch {
setError(t("launcherLogin.errorNetwork"))
} finally {
setSubmitting(false)
}
},
[t],
)
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
await loginWithToken(token)
}
return (
<div className="bg-background text-foreground flex min-h-dvh flex-col">
<header className="border-border/50 flex h-14 shrink-0 items-center justify-end gap-2 border-b px-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" aria-label="Language">
<IconLanguage className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => i18n.changeLanguage("en")}>
English
</DropdownMenuItem>
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="icon"
type="button"
onClick={() => toggleTheme()}
aria-label={theme === "dark" ? "Light mode" : "Dark mode"}
>
{theme === "dark" ? (
<IconSun className="size-4" />
) : (
<IconMoon className="size-4" />
)}
</Button>
</header>
<div className="flex flex-1 items-center justify-center p-4">
<Card className="w-full max-w-md" size="sm">
<CardHeader>
<CardTitle>{t("launcherLogin.title")}</CardTitle>
<CardDescription>{t("launcherLogin.description")}</CardDescription>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="launcher-token">
{t("launcherLogin.tokenLabel")}
</Label>
<Input
id="launcher-token"
name="token"
type="password"
autoComplete="current-password"
required
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={t("launcherLogin.tokenPlaceholder")}
/>
</div>
<Button type="submit" disabled={submitting}>
{submitting ? t("labels.loading") : t("launcherLogin.submit")}
</Button>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
</form>
{tokenHelp ? (
<div className="border-border/60 mt-6 border-t pt-4">
<p className="text-muted-foreground mb-2 text-sm font-medium">
{t("launcherLogin.helpTitle")}
</p>
<ul className="text-muted-foreground list-inside list-disc space-y-1.5 text-sm">
{tokenHelp.console_stdout ? (
<li>{t("launcherLogin.helpConsole")}</li>
) : null}
{tokenHelp.tray_copy_menu ? (
<li>{t("launcherLogin.helpTray")}</li>
) : null}
{tokenHelp.log_file ? (
<li>
{t("launcherLogin.helpLogFile", {
path: tokenHelp.log_file,
})}
</li>
) : null}
{tokenHelp.env_var_name ? (
<li>
{t("launcherLogin.helpEnv", {
env: tokenHelp.env_var_name,
})}
</li>
) : null}
</ul>
</div>
) : null}
</CardContent>
</Card>
</div>
</div>
)
}
export const Route = createFileRoute("/launcher-login")({
component: LauncherLoginPage,
})