feat(dashboard): bearer-token auth with browser persistence + MIT license

Pre-release polish. Two related changes bundled because they both land the
project on public-release footing:

Dashboard auth
- fn dashboard now gates the HTTP API + terminal/badge WebSockets behind a
  bearer token by default. Token resolution order: --token flag,
  FUSION_DASHBOARD_TOKEN env, FUSION_DAEMON_TOKEN env (back-compat), or an
  auto-generated fn_<32 hex>. --no-auth disables. The startup banner prints
  a click-to-open URL with ?token=<token> embedded.
- Auth middleware now also accepts fn_token=<token> as a query-string
  fallback so EventSource and WebSocket clients (which can't set custom
  headers) still authenticate.
- setupTerminalWebSocket / setupBadgeWebSocket now refuse unauthenticated
  upgrades with a proper 401 + socket close.
- Frontend: new auth.ts module captures ?token= off the URL into
  localStorage (key fn.authToken), strips it from the visible URL via
  replaceState, and installs a window.fetch wrapper that injects
  Authorization: Bearer <token> on every same-origin /api/* request.
  EventSource/WebSocket URL builders (api.ts, sse-bus.ts, useTerminal,
  useBadgeWebSocket) route through appendTokenQuery().

MIT license
- LICENSE file at repo root.
- license: "MIT" on root package.json and every packages/*/package.json,
  plus description/bugs metadata on the CLI package.

Docs
- docs/cli-reference.md documents --token / --no-auth / FUSION_DASHBOARD_TOKEN
  and the click-to-open auth flow.
- docs/getting-started.md, docs/docker.md, README.md point at the new flow
  and the CLI reference section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-21 18:01:20 -07:00
parent 04f8457c03
commit 7e3c68249e
24 changed files with 496 additions and 68 deletions

View File

@@ -1,6 +1,12 @@
{
"name": "@gsxdsm/fusion",
"version": "0.4.0",
"license": "MIT",
"description": "Fusion CLI: HTTP API server, daemon, dashboard launcher, and task tooling for the Fusion AI coding agent.",
"homepage": "https://github.com/Runfusion/Fusion#readme",
"bugs": {
"url": "https://github.com/Runfusion/Fusion/issues"
},
"type": "module",
"keywords": [
"pi-package"

View File

@@ -269,6 +269,8 @@ Options:
--project, -P <name> Target a specific project (bypasses CWD detection)
--port, -p <port> Dashboard/serve port (default: 4040)
--host <host> Serve host (default: 127.0.0.1 — localhost only; pass 0.0.0.0 to expose)
--token <token> Dashboard/daemon bearer token. Default: $FUSION_DASHBOARD_TOKEN, $FUSION_DAEMON_TOKEN, or auto-generated.
--no-auth Disable dashboard bearer-token auth (local-only; not recommended on 0.0.0.0)
--interactive Interactive mode (port selection for dashboard, issue selection for import)
--paused Start with engine paused (automation disabled)
--dev Start dashboard only (no AI engine)
@@ -506,7 +508,10 @@ async function main() {
const interactive = args.includes("--interactive");
const dashHostIdx = args.indexOf("--host");
const host = dashHostIdx !== -1 && dashHostIdx + 1 < args.length ? args[dashHostIdx + 1] : undefined;
await runDashboard(port, { paused, dev, interactive, host });
const noAuth = args.includes("--no-auth");
const dashTokenIdx = args.indexOf("--token");
const token = dashTokenIdx !== -1 && dashTokenIdx + 1 < args.length ? args[dashTokenIdx + 1] : undefined;
await runDashboard(port, { paused, dev, interactive, host, noAuth, token });
break;
}

View File

@@ -1,4 +1,5 @@
import type { AddressInfo } from "node:net";
import { randomBytes } from "node:crypto";
import { join } from "node:path";
import { TaskStore, AutomationStore, CentralCore, AgentStore, PluginStore, PluginLoader, getTaskMergeBlocker, getEnabledPiExtensionPaths } from "@fusion/core";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv } from "@fusion/dashboard";
@@ -193,10 +194,30 @@ async function resolveRuntimeProjectPath(): Promise<string> {
}
}
export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean; host?: string } = {}) {
export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean; host?: string; noAuth?: boolean; token?: string } = {}) {
// Default to localhost so the dashboard (and its shell-capable terminal API)
// is not exposed on the LAN. Pass --host 0.0.0.0 explicitly to opt-in.
const selectedHost = opts.host ?? "127.0.0.1";
// ── Bearer-token auth ────────────────────────────────────────────────
//
// By default the dashboard API is gated by a bearer token so that when the
// server is bound to a non-localhost interface (e.g. `pnpm dev dashboard`
// which injects --host 0.0.0.0 for LAN testing) nearby users can't hit the
// terminal or exec endpoints uninvited. Precedence:
// 1. `opts.token` — explicit override (mostly for tests)
// 2. `FUSION_DASHBOARD_TOKEN` — user-provided env
// 3. `FUSION_DAEMON_TOKEN` — back-compat with daemon mode
// 4. auto-generated random token (printed at startup so the user can auth)
// `--no-auth` skips the middleware entirely. The token is embedded in the
// launch URL (as `?token=...`) so the user can click once and the browser
// stores it to localStorage for subsequent loads.
const dashboardAuthToken: string | undefined = opts.noAuth
? undefined
: opts.token
?? process.env.FUSION_DASHBOARD_TOKEN
?? process.env.FUSION_DAEMON_TOKEN
?? `fn_${randomBytes(16).toString("hex")}`;
ensureProcessDiagnostics();
// Handle interactive port selection
@@ -606,6 +627,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
skillsAdapter,
https: loadTlsCredentialsFromEnv(),
daemon: dashboardAuthToken ? { token: dashboardAuthToken } : undefined,
});
const shutdown = async (signal: NodeJS.Signals) => {
@@ -787,6 +809,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
pluginRunner: pluginLoader,
skillsAdapter,
https: loadTlsCredentialsFromEnv(),
daemon: dashboardAuthToken ? { token: dashboardAuthToken } : undefined,
});
}
@@ -918,10 +941,29 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
}
// Compose the user-visible URL. When we're bound to a non-localhost
// interface (LAN testing), surface the actual host so the URL is
// usable from another device. Otherwise keep it as `localhost` for
// the nicer click-to-open experience.
const displayHost =
selectedHost === "0.0.0.0" || selectedHost === "::" ? selectedHost : "localhost";
const baseUrl = `http://${displayHost}:${actualPort}`;
const tokenizedUrl = dashboardAuthToken
? `${baseUrl}/?token=${encodeURIComponent(dashboardAuthToken)}`
: baseUrl;
console.log();
console.log(` fn board`);
console.log(` ────────────────────────`);
console.log(`http://localhost:${actualPort}`);
console.log(`${baseUrl}`);
if (dashboardAuthToken) {
console.log(` Auth: bearer token required`);
console.log(` Token: ${dashboardAuthToken}`);
console.log(` Open: ${tokenizedUrl}`);
console.log(` (the browser stores the token so you only need to click once)`);
} else {
console.log(` Auth: disabled (--no-auth)`);
}
console.log();
console.log(` Tasks stored in .fusion/tasks/`);
console.log(` Merge: AI-assisted (conflict resolution + commit messages)`);

View File

@@ -1,6 +1,7 @@
{
"name": "@fusion/core",
"version": "0.1.0",
"license": "MIT",
"description": "Fusion core: task store, scheduler, settings, and shared domain types backing the Fusion AI coding agent.",
"homepage": "https://github.com/Runfusion/Fusion#readme",
"repository": {

View File

@@ -65,6 +65,7 @@ import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
import type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry } from "@fusion/dashboard";
import type { MilestoneValidationTelemetry } from "./components/mission-types";
import { appendTokenQuery } from "./auth";
// Re-export skills types for use by hooks and components
export type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry };
@@ -1889,7 +1890,8 @@ function createResilientEventSource(
if (closedByUser) return;
const nextUrl = appendLastEventId(url, lastSeenEventId);
const source = new EventSource(nextUrl);
// EventSource can't set headers — carry the bearer token via `fn_token=`.
const source = new EventSource(appendTokenQuery(nextUrl));
eventSource = source;
source.onopen = () => {

View File

@@ -0,0 +1,188 @@
/**
* Dashboard authentication: token capture, storage, and injection.
*
* Flow:
* 1. On first load, if `?token=<value>` is present in the URL, capture it,
* store it in localStorage, and strip it from the visible URL so the
* secret doesn't end up in browser history or shared screenshots.
* 2. `getAuthToken()` returns the stored token (or undefined if none).
* 3. `installAuthFetch()` wraps `window.fetch` to inject
* `Authorization: Bearer <token>` on every same-origin `/api/*` call,
* and rewrites EventSource-style URLs by appending `fn_token=<token>`
* (EventSource can't set headers).
* 4. `appendTokenQuery()` and `withTokenHeader()` are helpers for places
* that construct URLs directly (WebSocket upgrades, EventSource).
*
* If no token is configured (dashboard started with `--no-auth`), all of the
* above no-ops — the fetch wrapper adds nothing and `appendTokenQuery` is
* identity.
*/
const STORAGE_KEY = "fn.authToken";
const URL_PARAM = "token";
/** Query param name used when we can't set an Authorization header (EventSource, WebSocket). */
export const QUERY_TOKEN_PARAM = "fn_token";
let cachedToken: string | undefined;
let captureAttempted = false;
function readStoredToken(): string | undefined {
try {
const value = window.localStorage.getItem(STORAGE_KEY);
return value && value.length > 0 ? value : undefined;
} catch {
return undefined;
}
}
function writeStoredToken(token: string): void {
try {
window.localStorage.setItem(STORAGE_KEY, token);
} catch {
// Private mode / storage disabled — fall through; token stays in memory.
}
}
/**
* Read the `?token=...` param off the current URL (if present) and stash it
* into localStorage, then remove it from the visible URL so the secret is not
* retained in browser history. Returns the token if one was captured.
*
* Safe to call multiple times — only the first call does work.
*/
function captureTokenFromUrl(): string | undefined {
if (captureAttempted || typeof window === "undefined") {
return undefined;
}
captureAttempted = true;
try {
const url = new URL(window.location.href);
const token = url.searchParams.get(URL_PARAM);
if (!token) {
return undefined;
}
writeStoredToken(token);
url.searchParams.delete(URL_PARAM);
const cleaned = url.pathname + (url.search ? url.search : "") + url.hash;
window.history.replaceState(window.history.state, "", cleaned);
return token;
} catch {
return undefined;
}
}
/** Return the bearer token in effect for this session, if any. */
export function getAuthToken(): string | undefined {
if (cachedToken !== undefined) {
return cachedToken;
}
const captured = captureTokenFromUrl();
if (captured) {
cachedToken = captured;
return captured;
}
const stored = readStoredToken();
if (stored) {
cachedToken = stored;
return stored;
}
return undefined;
}
/** Clear the stored token (e.g., on a 401 response). */
export function clearAuthToken(): void {
cachedToken = undefined;
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore — worst case, a stale token sits in memory until reload.
}
}
/** Append `fn_token=<token>` to a URL so EventSource / WebSocket can auth. */
export function appendTokenQuery(url: string): string {
const token = getAuthToken();
if (!token) {
return url;
}
try {
// Support both absolute and relative URLs by using a dummy base.
const base = url.startsWith("/") || !/^[a-z]+:\/\//i.test(url)
? new URL(url, window.location.origin)
: new URL(url);
base.searchParams.set(QUERY_TOKEN_PARAM, token);
// Preserve the original form (relative vs absolute).
return url.startsWith("/")
? base.pathname + base.search + base.hash
: base.toString();
} catch {
// URL too malformed to parse — fall back to naive concatenation.
const sep = url.includes("?") ? "&" : "?";
return `${url}${sep}${QUERY_TOKEN_PARAM}=${encodeURIComponent(token)}`;
}
}
/** Merge an Authorization header onto an existing HeadersInit, if we have a token. */
export function withTokenHeader(init?: HeadersInit): HeadersInit | undefined {
const token = getAuthToken();
if (!token) {
return init;
}
const headers = new Headers(init ?? {});
if (!headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`);
}
return headers;
}
/**
* Monkey-patch `window.fetch` once so every same-origin `/api/*` request gets
* a bearer token. This covers direct `fetch()` callers that don't route
* through the `api()` helper without requiring us to touch each one.
*/
export function installAuthFetch(): void {
if (typeof window === "undefined" || (window as any).__fnAuthFetchInstalled) {
return;
}
(window as any).__fnAuthFetchInstalled = true;
// Ensure token is captured-from-URL before the first fetch fires.
getAuthToken();
const originalFetch = window.fetch.bind(window);
window.fetch = function patchedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const token = getAuthToken();
if (!token) {
return originalFetch(input, init);
}
const urlString = typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
// Only attach the token for same-origin /api/* requests.
const isApiCall = (() => {
try {
const resolved = new URL(urlString, window.location.origin);
if (resolved.origin !== window.location.origin) return false;
return resolved.pathname.startsWith("/api/") || resolved.pathname === "/api";
} catch {
return urlString.startsWith("/api/") || urlString === "/api";
}
})();
if (!isApiCall) {
return originalFetch(input, init);
}
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
if (!headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`);
}
return originalFetch(input, { ...init, headers });
};
}

View File

@@ -24,6 +24,7 @@
*/
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
import type { IssueInfo, PrInfo } from "@fusion/core";
import { appendTokenQuery } from "../auth";
interface BadgeUpdatedMessage {
type: "badge:updated";
@@ -194,7 +195,9 @@ class BadgeWebSocketStore {
if (this.projectId) {
url += `?projectId=${encodeURIComponent(this.projectId)}`;
}
const ws = new WebSocket(url);
// Bearer token must be on the URL — WebSocket construction doesn't
// support custom headers. No-op when auth is disabled.
const ws = new WebSocket(appendTokenQuery(url));
this.ws = ws;
ws.onopen = () => {

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { appendTokenQuery } from "../auth";
export type ConnectionStatus = "connecting" | "connected" | "disconnected" | "reconnecting";
@@ -278,7 +279,10 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
wsUrl += `&projectId=${encodeURIComponent(projectId)}`;
}
const ws = new WebSocket(wsUrl);
// Carry the bearer token on the URL — WebSocket `new WebSocket` can't set
// an Authorization header. `appendTokenQuery` adds `fn_token=<token>`
// when auth is active and returns the URL unchanged otherwise.
const ws = new WebSocket(appendTokenQuery(wsUrl));
wsRef.current = ws;
ws.onopen = () => {

View File

@@ -2,8 +2,15 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RootErrorBoundary } from "./components/ErrorBoundary";
import { App } from "./App";
import { installAuthFetch } from "./auth";
import "./styles.css";
// Install the bearer-token fetch wrapper before React mounts so every API
// call (including ones fired synchronously during the first render) picks up
// the token that was either captured from `?token=` in the launch URL or
// stored from a previous session.
installAuthFetch();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<RootErrorBoundary>

View File

@@ -1,3 +1,5 @@
import { appendTokenQuery } from "./auth";
// Shared EventSource multiplexer.
//
// Browsers cap HTTP/1.1 connections to a single origin at ~6. Each native
@@ -86,7 +88,10 @@ function openChannel(channel: Channel): void {
channel.reconnectTimer = null;
}
const es = new EventSource(channel.url);
// EventSource can't set custom headers, so the bearer token must ride on
// the URL as `fn_token=<token>`. `appendTokenQuery` is a no-op when no
// token is configured.
const es = new EventSource(appendTokenQuery(channel.url));
channel.es = es;
es.addEventListener("open", () => {

View File

@@ -1,6 +1,7 @@
{
"name": "@fusion/dashboard",
"version": "0.1.0",
"license": "MIT",
"description": "Fusion dashboard: React UI and HTTP API server for monitoring and controlling the Fusion AI coding agent.",
"homepage": "https://github.com/Runfusion/Fusion#readme",
"repository": {

View File

@@ -7,6 +7,14 @@
import { timingSafeEqual } from "node:crypto";
import type { Request, Response, NextFunction } from "express";
import type { IncomingMessage } from "node:http";
/**
* Query-string fallback used when the client can't set an Authorization
* header (EventSource, WebSocket handshake). The token flows as
* `?fn_token=<token>` on those URLs.
*/
export const TOKEN_QUERY_PARAM = "fn_token";
/** Paths that are exempt from authentication (liveness probes). */
const EXEMPT_PATHS = ["/api/health"];
@@ -30,7 +38,7 @@ export function isDaemonAuthActive(options?: { daemon?: { token: string } }): bo
/**
* Get the daemon token from options or environment.
*/
function getDaemonToken(options?: { daemon?: { token: string } }): string | undefined {
export function getDaemonToken(options?: { daemon?: { token: string } }): string | undefined {
if (options?.daemon?.token) {
return options.daemon.token;
}
@@ -44,17 +52,85 @@ function isExemptPath(path: string): boolean {
return EXEMPT_PATHS.some((exempt) => path === exempt || path.startsWith(exempt + "/"));
}
/**
* Constant-time string compare. Returns true only if both strings are the
* same length and byte-for-byte equal.
*/
function constantTimeEqual(provided: string, expected: Buffer): boolean {
if (provided.length !== expected.length) {
return false;
}
try {
const providedBuffer = Buffer.from(provided, "utf8");
if (providedBuffer.length !== expected.length) {
return false;
}
return timingSafeEqual(providedBuffer, expected);
} catch {
return false;
}
}
/**
* Extract a bearer token from either the `Authorization: Bearer <token>`
* header or the `fn_token=<token>` query-string fallback. The query-string
* path is only needed for transports that can't set headers (EventSource,
* WebSocket handshake).
*/
function extractTokenFromRequest(req: { headers: { authorization?: string }; url?: string }): string | undefined {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith("Bearer ")) {
return authHeader.slice(7);
}
if (req.url) {
try {
const parsed = new URL(req.url, "http://_placeholder_");
const fromQuery = parsed.searchParams.get(TOKEN_QUERY_PARAM);
if (fromQuery) return fromQuery;
} catch {
// Fall through — malformed URL, treat as no token.
}
}
return undefined;
}
/**
* Validate a raw HTTP upgrade request (WebSocket handshake) against the
* configured daemon token. Returns true when the request carries a valid
* bearer token, false otherwise. Accepts the token either via the
* `Authorization` header or the `fn_token` query string — browsers cannot
* set custom headers on a WebSocket constructor, so the query-string
* fallback is required for same-origin browser clients.
*
* Uses constant-time comparison to resist timing attacks.
*/
export function authenticateUpgradeRequest(token: string, req: IncomingMessage): boolean {
const expectedBuffer = Buffer.from(token, "utf8");
const provided = extractTokenFromRequest(req as { headers: { authorization?: string }; url?: string });
if (!provided) return false;
return constantTimeEqual(provided, expectedBuffer);
}
/**
* Create Express middleware that enforces bearer token authentication.
*
* Uses constant-time comparison to prevent timing attacks.
* Exempts /api/health and paths starting with /api/health/ from auth.
* Accepts the token either in the `Authorization: Bearer <token>` header
* (preferred) or as a `fn_token=<token>` query parameter — the latter is
* needed by EventSource and WebSocket clients which can't send headers.
*
* @param token - The valid bearer token
* @returns Express middleware function
*/
export function createAuthMiddleware(token: string) {
const expectedBuffer = Buffer.from(token, "utf8");
const unauthorized = (res: Response): void => {
res.status(401).json({
error: "Unauthorized",
message: "Valid bearer token required",
});
};
return function authMiddleware(req: Request, res: Response, next: NextFunction): void {
// Always allow exempt paths
@@ -63,67 +139,17 @@ export function createAuthMiddleware(token: string) {
return;
}
// Extract Authorization header
const authHeader = req.headers.authorization;
if (!authHeader) {
res.status(401).json({
error: "Unauthorized",
message: "Valid bearer token required",
});
const providedToken = extractTokenFromRequest(req);
if (!providedToken) {
unauthorized(res);
return;
}
// Parse Bearer scheme
if (!authHeader.startsWith("Bearer ")) {
res.status(401).json({
error: "Unauthorized",
message: "Valid bearer token required",
});
if (!constantTimeEqual(providedToken, expectedBuffer)) {
unauthorized(res);
return;
}
const providedToken = authHeader.slice(7); // Remove "Bearer " prefix
// Fast path: check length first to avoid unnecessary crypto calls
if (providedToken.length !== expectedBuffer.length) {
res.status(401).json({
error: "Unauthorized",
message: "Valid bearer token required",
});
return;
}
// Constant-time comparison to prevent timing attacks
try {
const providedBuffer = Buffer.from(providedToken, "utf8");
// Ensure buffers are the same length (they should be due to length check above)
if (providedBuffer.length !== expectedBuffer.length) {
res.status(401).json({
error: "Unauthorized",
message: "Valid bearer token required",
});
return;
}
if (!timingSafeEqual(providedBuffer, expectedBuffer)) {
res.status(401).json({
error: "Unauthorized",
message: "Valid bearer token required",
});
return;
}
} catch {
// Buffer encoding issues or other crypto errors
res.status(401).json({
error: "Unauthorized",
message: "Valid bearer token required",
});
return;
}
// Token is valid
next();
};
}

View File

@@ -44,7 +44,7 @@ import {
import { ChatManager } from "./chat.js";
import { stopAllDevServers } from "./dev-server-routes.js";
import type { SkillsAdapter } from "./skills-adapter.js";
import { createAuthMiddleware } from "./auth-middleware.js";
import { createAuthMiddleware, authenticateUpgradeRequest, getDaemonToken } from "./auth-middleware.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -866,12 +866,25 @@ export function setupTerminalWebSocket(
// Default terminal service for stale eviction (uses default store's root dir)
const defaultTerminalService = getTerminalService(store.getRootDir());
// Resolve the daemon token once so every upgrade picks up the same value.
const wsDaemonToken = getDaemonToken(options);
server.on("upgrade", (req, socket, head) => {
const pathname = new URL(req.url || "", `http://${req.headers.host}`).pathname;
if (pathname !== "/api/terminal/ws") {
return;
}
// When daemon auth is active, refuse WebSocket upgrades that don't
// carry a valid bearer token. The token can come from the Authorization
// header (rare for browser WebSocket clients) or the `fn_token` query
// param (what our own client uses).
if (wsDaemonToken && !authenticateUpgradeRequest(wsDaemonToken, req)) {
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (upgraded) => {
wss.emit("connection", upgraded, req);
});
@@ -1130,12 +1143,22 @@ export function setupBadgeWebSocket(
const wss = new WebSocketServer({ noServer: true });
// Resolve the daemon token once per server so every upgrade picks up the
// same value. See the equivalent block in setupTerminalWebSocket above.
const badgeWsDaemonToken = getDaemonToken(options);
server.on("upgrade", (req, socket, head) => {
const pathname = new URL(req.url || "", `http://${req.headers.host}`).pathname;
if (pathname !== "/api/ws") {
return;
}
if (badgeWsDaemonToken && !authenticateUpgradeRequest(badgeWsDaemonToken, req)) {
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (upgraded) => {
wss.emit("connection", upgraded, req);
});

View File

@@ -1,6 +1,7 @@
{
"name": "@fusion/desktop",
"version": "0.1.0",
"license": "MIT",
"description": "Fusion desktop: Electron wrapper around the Fusion dashboard for macOS, Windows, and Linux.",
"homepage": "https://github.com/Runfusion/Fusion#readme",
"repository": {

View File

@@ -1,6 +1,7 @@
{
"name": "@fusion/engine",
"version": "0.1.0",
"license": "MIT",
"description": "Fusion engine: executor, merger, scheduler, and automation runtime for the Fusion AI coding agent.",
"homepage": "https://github.com/Runfusion/Fusion#readme",
"repository": {

View File

@@ -1,6 +1,7 @@
{
"name": "@fusion/mobile",
"version": "0.1.0",
"license": "MIT",
"description": "Fusion mobile: Capacitor wrapper around the Fusion dashboard for iOS and Android.",
"homepage": "https://github.com/Runfusion/Fusion#readme",
"repository": {

View File

@@ -1,6 +1,7 @@
{
"name": "@fusion/plugin-sdk",
"version": "0.1.0",
"license": "MIT",
"description": "Fusion plugin SDK: types and helpers for authoring third-party plugins that extend the Fusion dashboard and engine.",
"homepage": "https://github.com/Runfusion/Fusion#readme",
"repository": {

View File

@@ -1,6 +1,7 @@
{
"name": "@fusion/tui",
"version": "0.1.0",
"license": "MIT",
"description": "Fusion TUI: terminal UI for interacting with the Fusion task store and AI coding agent.",
"homepage": "https://github.com/Runfusion/Fusion#readme",
"repository": {