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 {